Skip to content

第 7 章 field_validator 自定义字段校验 ​

上一章里,我们已经能用嵌套模型描述用户、文章和评论,也能通过 loc 定位嵌套字段错误。

例如:

python
from pydantic import BaseModel, Field


class UserInfo(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    email: str


class CommentCreate(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    content: str = Field(min_length=2, max_length=200)
    likes: int = Field(default=0, ge=0)


class ArticleDetail(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    author: UserInfo
    comments: list[CommentCreate] = Field(default_factory=list)

这些模型已经能处理类型、长度、范围、默认值和嵌套结构。接下来遇到的规则就没法只靠 Field 表达了:用户名要去掉前后空格、不能有中间空格;评论不能全是空白;标题不能带“广告”“推广”这样的词。

它们都只检查单个字段,但需要我们写一点判断逻辑,这正是 field_validator 要做的事。本章只讲单字段规则;要同时检查多个字段,下一章再用 model_validator。

本章学习目标 ​

类型注解和 Field 管不到的单字段业务规则,就交给 field_validator。这章会写 @field_validator("字段名"),在方法里清理前后空格、检查内容,不合规则时抛出 ValueError。你还要能读懂最终的 ValidationError,知道什么时候用 Field,什么时候写校验器。

字段校验器与执行阶段 ​

这一章继续使用文章发布数据。

文章数据里有三类这样的规则:

text
UserInfo.username
    -> 去掉前后空格
    -> 不能包含中间空格

CommentCreate.content
    -> 去掉前后空格
    -> 不能是空白内容

ArticleDetail.title
    -> 不能包含简单敏感词,例如“广告”“推广”

它们都只看一个字段,不需要检查字段之间的关系,所以适合交给 field_validator。

先看一眼本章新增的能力:

field_validator 执行阶段与数据流转时序

前面学过类型注解、Field 和 ValidationError。这里再加上校验器的执行时机:

text
before 清理原始输入 → 类型和 Field 规则 → after 检查已校验的值

每一步都可以把完整代码复制为本地 .code/main.py,在 .code 目录运行。进入下一步时覆盖这个文件。

单字段规则与用户名校验 ​

Field 通用规则

Field 适合处理声明式规则。

例如:

python
username: str = Field(min_length=2, max_length=20)
likes: int = Field(default=0, ge=0)

这类规则通常包括:

  • 最小长度
  • 最大长度
  • 数值范围
  • 默认值
  • default_factory

这类规则直接写在字段上就够了。

field_validator 自定义规则

有些规则没法只靠 Field 表达。

例如:

text
用户名不能包含空格
评论内容不能全是空白
标题不能包含某些词

这时就要自己写 Python 逻辑。

例如:

python
@field_validator("username", mode="before")
@classmethod
def validate_username(cls, value: object) -> object:
    if not isinstance(value, str):
        return value
    value = value.strip()

    if " " in value:
        raise ValueError("用户名不能包含空格")

    return value

mode="before" 会让 strip() 在 Field(min_length=2, max_length=20) 之前执行。原始输入不一定是字符串,所以先用 isinstance() 判断。清理后把值交还给 Pydantic:" x " 会变成 "x",再被最小长度规则拦下;如果把清理放到默认的 after 模式,原始的三个字符会先通过长度检查。

先从 UserInfo.username 开始。

用户名要去掉前后空格,中间不能再有空格。

将下面的完整代码保存为 .code/main.py:

py
from pydantic import BaseModel, Field, ValidationError, field_validator


class UserInfo(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    email: str

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if " " in value:
            raise ValueError("用户名不能包含空格")

        return value


data = {
    "username": "  xiaoming  ",
    "email": "xiaoming@example.com",
}

user = UserInfo.model_validate(data)

print(user.username)
print(user.email)

short_data = {**data, "username": " x "}
try:
    UserInfo.model_validate(short_data)
except ValidationError as e:
    print("清理后用户名过短:")
    print(e)

运行:

bash
uv run python main.py
python
from pydantic import BaseModel, Field, ValidationError, field_validator


class UserInfo(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    email: str

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if " " in value:
            raise ValueError("用户名不能包含空格")

        return value


data = {
    "username": "  xiaoming  ",
    "email": "xiaoming@example.com",
}

user = UserInfo.model_validate(data)

print(user.username)
print(user.email)

short_data = {**data, "username": " x "}
try:
    UserInfo.model_validate(short_data)
except ValidationError as e:
    print("清理后用户名过短:")
    print(e)

可能看到类似输出:

text
xiaoming
xiaoming@example.com
清理后用户名过短:
1 validation error for UserInfo
username
  String should have at least 2 characters

校验器先清掉前后空格,再检查中间是否还有空格。通过后要 return value,把处理后的值交回给 Pydantic;所以 " x " 清理成 "x" 后会被 Field(min_length=2) 拦下。如果输入是:

python
"xiao ming"

就会触发:

python
raise ValueError("用户名不能包含空格")

可以把这段逻辑看成:

字段值清洗变换与后置规则联动管道

校验器的模型归属

谁的字段需要自定义校验,就把校验方法写在哪个模型里。

例如:

python
class UserInfo(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    email: str

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        ...

校验器写在 UserInfo 里,因为它检查的是这个模型的 username 字段。

Field 与 field_validator 的分工

可以用下面这张图快速区分两者:

Field 声明式约束 vs field_validator 逻辑校验选型矩阵

可以直接记成一句话:

Field 处理通用规则,field_validator 处理需要自己写逻辑的规则。

评论内容校验与清理 ​

字段值的接收与返回

默认的 after 校验器收到已通过基础规则的值,可以这样标注:

python
def validate_username(cls, value: str) -> str:

本章用于清理空格的 before 校验器收到原始输入,建议标注为 object 并先检查类型。不管是哪种模式,通过后都要返回接下来要校验或保存的值:

python
return value

接着用同样的方式处理评论:去掉前后空格,并拒绝清理后为空的内容。

将下面的完整代码保存为 .code/main.py:

py
from pydantic import BaseModel, Field, field_validator


class CommentCreate(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    content: str = Field(min_length=2, max_length=200)
    likes: int = Field(default=0, ge=0)

    @field_validator("content", mode="before")
    @classmethod
    def validate_content(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if not value:
            raise ValueError("评论内容不能为空")

        return value


data = {
    "username": "alice",
    "content": "  这篇文章很清晰  ",
    "likes": 3,
}

comment = CommentCreate.model_validate(data)

print(comment.username)
print(comment.content)
print(comment.likes)

运行:

bash
uv run python main.py
python
from pydantic import BaseModel, Field, field_validator


class CommentCreate(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    content: str = Field(min_length=2, max_length=200)
    likes: int = Field(default=0, ge=0)

    @field_validator("content", mode="before")
    @classmethod
    def validate_content(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if not value:
            raise ValueError("评论内容不能为空")

        return value


data = {
    "username": "alice",
    "content": "  这篇文章很清晰  ",
    "likes": 3,
}

comment = CommentCreate.model_validate(data)

print(comment.username)
print(comment.content)
print(comment.likes)

可能看到类似输出:

text
alice
这篇文章很清晰
3

Field(min_length=2) 能限制长度,却无法判断一串空格是不是有意义。清理并检查内容,需要 field_validator 来处理。

嵌套模型字段校验 ​

ValueError 与 ValidationError ​

如果字段不符合规则,通常写:

python
raise ValueError("用户名不能包含空格")

外层捕获时,仍然捕获的是:

python
except ValidationError as e:

也就是说:

  • 你在字段校验器里抛出的是 ValueError
  • Pydantic 最终整合出来的是 ValidationError

现在把 UserInfo、CommentCreate 和 ArticleDetail 组合起来,并分别校验用户名、评论内容和文章标题:

  1. UserInfo.username:去掉前后空格,禁止中间空格
  2. CommentCreate.content:去掉前后空格,禁止空白评论
  3. ArticleDetail.title:禁止包含 "广告"、"推广"

将下面的完整代码保存为 .code/main.py:

py
from pydantic import BaseModel, Field, ValidationError, field_validator


class UserInfo(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    email: str

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if " " in value:
            raise ValueError("用户名不能包含空格")

        return value


class CommentCreate(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    content: str = Field(min_length=2, max_length=200)
    likes: int = Field(default=0, ge=0)

    @field_validator("content", mode="before")
    @classmethod
    def validate_content(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if not value:
            raise ValueError("评论内容不能为空")

        return value


class ArticleDetail(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    author: UserInfo
    comments: list[CommentCreate] = Field(default_factory=list)

    @field_validator("title")
    @classmethod
    def validate_title(cls, value: str) -> str:
        blocked_words = ["广告", "推广"]

        for word in blocked_words:
            if word in value:
                raise ValueError("文章标题不能包含敏感词")

        return value


bad_data = {
    "title": "这是一篇广告文章",
    "content": "这是一篇介绍 field_validator 的文章内容",
    "author": {
        "username": "xiao ming",
        "email": "xiaoming@example.com",
    },
    "comments": [
        {
            "username": "alice",
            "content": "     ",
            "likes": 3,
        }
    ],
}

try:
    article = ArticleDetail.model_validate(bad_data)
    print(article)
except ValidationError as e:
    for error in e.errors():
        print(error)

运行:

bash
uv run python main.py

输出会包含三条结构化错误。先对照每条错误的 loc 和 msg:

text
('title',)                  Value error, 文章标题不能包含敏感词
('author', 'username')      Value error, 用户名不能包含空格
('comments', 0, 'content')  Value error, 评论内容不能为空

上面是从错误字典中摘出的两个键;脚本还会打印 type、input 等信息。

错误发生在不同模型里,最后仍会汇总到外层 ValidationError。loc 会沿着嵌套结构标出位置:

python
('title',)
('author', 'username')
('comments', 0, 'content')

依次表示标题、作者用户名和第 1 条评论内容。每个模型各管自己的字段:

text
用户字段规则放在 UserInfo
评论字段规则放在 CommentCreate
文章字段规则放在 ArticleDetail

校验后字段值 ​

这次换成正确数据,其中用户名和评论内容都带了前后空格,校验器会把它们清理掉。

将下面的完整代码保存为 .code/main.py:

py
from pydantic import BaseModel, Field, field_validator


class UserInfo(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    email: str

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if " " in value:
            raise ValueError("用户名不能包含空格")

        return value


class CommentCreate(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    content: str = Field(min_length=2, max_length=200)
    likes: int = Field(default=0, ge=0)

    @field_validator("content", mode="before")
    @classmethod
    def validate_content(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if not value:
            raise ValueError("评论内容不能为空")

        return value


class ArticleDetail(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    author: UserInfo
    comments: list[CommentCreate] = Field(default_factory=list)

    @field_validator("title")
    @classmethod
    def validate_title(cls, value: str) -> str:
        blocked_words = ["广告", "推广"]

        for word in blocked_words:
            if word in value:
                raise ValueError("文章标题不能包含敏感词")

        return value


data = {
    "title": "Pydantic 字段校验",
    "content": "这是一篇介绍 field_validator 的文章内容",
    "author": {
        "username": "  xiaoming  ",
        "email": "xiaoming@example.com",
    },
    "comments": [
        {
            "username": "alice",
            "content": "  这篇文章很清晰  ",
            "likes": 3,
        }
    ],
}

article = ArticleDetail.model_validate(data)

print("文章标题:", article.title)
print("作者用户名:", article.author.username)
print("评论内容:", article.comments[0].content)

运行:

bash
uv run python main.py
python
from pydantic import BaseModel, Field, field_validator


class UserInfo(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    email: str

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if " " in value:
            raise ValueError("用户名不能包含空格")

        return value


class CommentCreate(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    content: str = Field(min_length=2, max_length=200)
    likes: int = Field(default=0, ge=0)

    @field_validator("content", mode="before")
    @classmethod
    def validate_content(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if not value:
            raise ValueError("评论内容不能为空")

        return value


class ArticleDetail(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    author: UserInfo
    comments: list[CommentCreate] = Field(default_factory=list)

    @field_validator("title")
    @classmethod
    def validate_title(cls, value: str) -> str:
        blocked_words = ["广告", "推广"]

        for word in blocked_words:
            if word in value:
                raise ValueError("文章标题不能包含敏感词")

        return value


data = {
    "title": "Pydantic 字段校验",
    "content": "这是一篇介绍 field_validator 的文章内容",
    "author": {
        "username": "  xiaoming  ",
        "email": "xiaoming@example.com",
    },
    "comments": [
        {
            "username": "alice",
            "content": "  这篇文章很清晰  ",
            "likes": 3,
        }
    ],
}

article = ArticleDetail.model_validate(data)

print("文章标题:", article.title)
print("作者用户名:", article.author.username)
print("评论内容:", article.comments[0].content)

可能看到类似输出:

text
文章标题: Pydantic 字段校验
作者用户名: xiaoming
评论内容: 这篇文章很清晰

校验后,用户名和评论内容都没有首尾空格。模型对象保存的是校验器返回的值。

可以再用一张小图单独看“值是怎么被清理并保存下来的”:

字段值清洗变换与后置规则联动管道

文章字段校验综合案例 ​

将下面的完整代码保存为 .code/main.py:

py
from pydantic import BaseModel, Field, ValidationError, field_validator


class UserInfo(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    email: str

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if " " in value:
            raise ValueError("用户名不能包含空格")

        return value


class CommentCreate(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    content: str = Field(min_length=2, max_length=200)
    likes: int = Field(default=0, ge=0)

    @field_validator("content", mode="before")
    @classmethod
    def validate_content(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if not value:
            raise ValueError("评论内容不能为空")

        return value


class ArticleDetail(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    author: UserInfo
    comments: list[CommentCreate] = Field(default_factory=list)

    @field_validator("title")
    @classmethod
    def validate_title(cls, value: str) -> str:
        blocked_words = ["广告", "推广"]

        for word in blocked_words:
            if word in value:
                raise ValueError("文章标题不能包含敏感词")

        return value


data = {
    "title": "Pydantic 字段校验",
    "content": "这是一篇介绍 field_validator 的文章内容",
    "author": {
        "username": "  xiaoming  ",
        "email": "xiaoming@example.com",
    },
    "comments": [
        {
            "username": "alice",
            "content": "  这篇文章很清晰  ",
            "likes": 3,
        },
        {
            "username": "bob",
            "content": "  期待下一章  ",
        },
    ],
}

try:
    article = ArticleDetail.model_validate(data)
    print("文章标题:", article.title)
    print("作者用户名:", article.author.username)
    print("第一条评论内容:", article.comments[0].content)
    print("第二条评论点赞数:", article.comments[1].likes)
except ValidationError as e:
    print("正确数据校验失败")
    print(e)


bad_data = {
    "title": "这是一篇广告文章",
    "content": "这是一篇介绍 field_validator 的文章内容",
    "author": {
        "username": "xiao ming",
        "email": "xiaoming@example.com",
    },
    "comments": [
        {
            "username": "alice",
            "content": "     ",
            "likes": 3,
        }
    ],
}

try:
    bad_article = ArticleDetail.model_validate(bad_data)
    print(bad_article)
except ValidationError as e:
    print("错误数据校验失败")
    print(e)

    for error in e.errors():
        location = " -> ".join(str(item) for item in error["loc"])
        print("错误位置:", location)
        print("错误说明:", error["msg"])
        print("输入值:", error["input"])
        print("-" * 30)

运行:

bash
uv run python main.py
python
from pydantic import BaseModel, Field, ValidationError, field_validator


class UserInfo(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    email: str

    @field_validator("username", mode="before")
    @classmethod
    def validate_username(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if " " in value:
            raise ValueError("用户名不能包含空格")

        return value


class CommentCreate(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    content: str = Field(min_length=2, max_length=200)
    likes: int = Field(default=0, ge=0)

    @field_validator("content", mode="before")
    @classmethod
    def validate_content(cls, value: object) -> object:
        if not isinstance(value, str):
            return value
        value = value.strip()

        if not value:
            raise ValueError("评论内容不能为空")

        return value


class ArticleDetail(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    author: UserInfo
    comments: list[CommentCreate] = Field(default_factory=list)

    @field_validator("title")
    @classmethod
    def validate_title(cls, value: str) -> str:
        blocked_words = ["广告", "推广"]

        for word in blocked_words:
            if word in value:
                raise ValueError("文章标题不能包含敏感词")

        return value


data = {
    "title": "Pydantic 字段校验",
    "content": "这是一篇介绍 field_validator 的文章内容",
    "author": {
        "username": "  xiaoming  ",
        "email": "xiaoming@example.com",
    },
    "comments": [
        {
            "username": "alice",
            "content": "  这篇文章很清晰  ",
            "likes": 3,
        },
        {
            "username": "bob",
            "content": "  期待下一章  ",
        },
    ],
}

try:
    article = ArticleDetail.model_validate(data)
    print("文章标题:", article.title)
    print("作者用户名:", article.author.username)
    print("第一条评论内容:", article.comments[0].content)
    print("第二条评论点赞数:", article.comments[1].likes)
except ValidationError as e:
    print("正确数据校验失败")
    print(e)


bad_data = {
    "title": "这是一篇广告文章",
    "content": "这是一篇介绍 field_validator 的文章内容",
    "author": {
        "username": "xiao ming",
        "email": "xiaoming@example.com",
    },
    "comments": [
        {
            "username": "alice",
            "content": "     ",
            "likes": 3,
        }
    ],
}

try:
    bad_article = ArticleDetail.model_validate(bad_data)
    print(bad_article)
except ValidationError as e:
    print("错误数据校验失败")
    print(e)

    for error in e.errors():
        location = " -> ".join(str(item) for item in error["loc"])
        print("错误位置:", location)
        print("错误说明:", error["msg"])
        print("输入值:", error["input"])
        print("-" * 30)

字段校验器执行流程

下面这张图把本章 3 个字段校验器的执行位置串起来:

嵌套模型多校验器协同执行与错误汇聚全景

这张图说明:

嵌套模型中,每个模型仍然负责自己的字段校验,最终错误会被汇总起来。

这个案例把本章几件事串在一起:校验器既能清理字段值,也能用 ValueError 拒绝不符合规则的输入;嵌套模型各自校验字段,错误最后汇总到外层 ValidationError。

本章小结 ​

field_validator 处理单个字段的自定义规则。方法通常写在模型类里,配合 @classmethod;通过校验就返回要保存的值,不通过则抛出 ValueError,由 Pydantic 整理成 ValidationError。如果规则要同时看几个字段,下一章再用 model_validator。