Skip to content

第 8 章 model_validator 跨字段校验 ​

前面几章我们处理过类型、Field 约束,也给单个字段加过自定义校验。接下来要解决的情况更常见,也更容易放错位置:一条规则同时依赖几个字段。

比如:

  • 文章已发布,就必须填写摘要
  • 关闭评论后,评论列表必须为空
  • 未发布文章的阅读量必须为 0
  • 标签里有 "精选",文章必须已发布

要判断它们是否成立,光看某一个字段都不够。本章会用 model_validator 把这类规则放到模型层处理。

本章学习目标 ​

规则只看一个字段,还是得把几个字段放在一起判断?先分清 field_validator 和 model_validator 的职责,再用 @model_validator(mode="after") 写联合校验。你会通过 self.xxx 读取字段,规则不满足时抛出 ValueError,并把多字段规则整理得容易阅读和讲解。

模型级校验规则 ​

这一章还是围绕文章详情模型展开。先把要用到的字段列出来:

text
summary         -> 文章摘要
is_published    -> 是否发布
allow_comments  -> 是否允许评论
comments        -> 评论列表
views           -> 阅读量
tags            -> 标签列表

然后用它们表达四条规则:

text
规则 1:已发布文章必须有摘要
规则 2:关闭评论时,评论列表必须为空
规则 3:未发布文章阅读量必须为 0
规则 4:标签包含“精选”时,文章必须已发布

四条规则都要比较字段之间的状态,所以不适合塞进某个字段自己的校验器。下面每一步都提供完整示例,可以复制到 .code/main.py 运行;进入下一步时再用新示例覆盖它。

已发布文章摘要规则 ​

先用上一章的用户名校验做个对照。field_validator 只负责处理 username:

例如:

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

如果规则只和一个字段有关,就放在 field_validator 里。发布状态和摘要之间的关系,则需要模型级校验:

例如:

python
@model_validator(mode="after")
def validate_publish_summary(self):
    if self.is_published and not self.summary:
        raise ValueError("文章发布时必须填写摘要")
    return self

方法同时读取 self.is_published 和 self.summary,正好能表达这条规则。

mode="after" 的执行阶段

本章先集中看 after 模式:

python
@model_validator(mode="after")

它会在字段完成校验后运行。因此进入方法时,可以直接读取已校验的字段值,例如:

python
self.summary
self.comments
self.tags

字段校验与模型校验的分工

field_validator vs model_validator 职责边界与选型决策

记这个分工就够用:field_validator 处理一个字段,model_validator 处理字段之间的关系。

模型校验执行流程

这一章只讲 mode="after",它的执行顺序可以看成下面这样:

包含 model_validator 的端到端执行全流程

图里有三个关键节点:

  1. before 字段校验器先处理原始输入,after 字段校验器在类型与 Field 规则之后执行。
  2. 只有字段校验通过,才会进入 model_validator(mode="after")。
  3. 所以这里适合写“多个字段之间的规则”。

先看最小例子,只校验发布状态和摘要。

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

py
from pydantic import BaseModel, Field, ValidationError, model_validator


class ArticleDetail(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    summary: str | None = Field(default=None, max_length=100)
    is_published: bool = Field(default=False)

    @model_validator(mode="after")
    def validate_publish_summary(self):
        if self.is_published and not self.summary:
            raise ValueError("文章发布时必须填写摘要")
        return self


bad_data = {
    "title": "Pydantic 联合校验",
    "content": "这是一篇介绍 model_validator 的文章内容",
    "is_published": True,
}

try:
    ArticleDetail.model_validate(bad_data)
except ValidationError as e:
    print("校验失败:")
    for error in e.errors():
        print(error["msg"])

运行:

bash
uv run python main.py

这次会因为文章已发布、但没有摘要而失败:

text
校验失败:
Value error, 文章发布时必须填写摘要
python
from pydantic import BaseModel, Field, ValidationError, model_validator

class ArticleDetail(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    summary: str | None = Field(default=None, max_length=100)
    is_published: bool = Field(default=False)

    @model_validator(mode="after")
    def validate_publish_summary(self):
        if self.is_published and not self.summary:
            raise ValueError("文章发布时必须填写摘要")
        return self

核心判断在这里:

python
if self.is_published and not self.summary:

条件同时读取两个字段,因此放进 model_validator。

对应关系可以看成这样:

跨字段业务规则判定流程

如果传入下面这份数据:

python
bad_data = {
    "title": "Pydantic 联合校验",
    "content": "这是一篇介绍 model_validator 的文章内容",
    "is_published": True,
}

就会因为“已发布但缺少摘要”而失败。

评论开关与评论列表 ​

mode="after" 校验通过后要返回模型对象,也就是 self:

python
return self

和字段校验器对照着记:

text
field_validator 通过后 return value
model_validator 通过后 return self

现在给模型加上评论开关和评论列表,再看一条联合规则:

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

py
from pydantic import BaseModel, Field, ValidationError, model_validator


class CommentCreate(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    content: str = Field(min_length=2, max_length=200)


class ArticleDetail(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    summary: str | None = Field(default=None, max_length=100)
    is_published: bool = Field(default=False)
    allow_comments: bool = Field(default=True)
    comments: list[CommentCreate] = Field(default_factory=list)

    @model_validator(mode="after")
    def validate_article_rules(self):
        if self.is_published and not self.summary:
            raise ValueError("文章发布时必须填写摘要")
        if not self.allow_comments and self.comments:
            raise ValueError("关闭评论时,评论列表必须为空")
        return self


bad_data = {
    "title": "Pydantic 联合校验",
    "content": "这是一篇介绍 model_validator 的文章内容",
    "summary": "介绍 model_validator 的基本用法",
    "is_published": True,
    "allow_comments": False,
    "comments": [
        {
            "username": "alice",
            "content": "写得很清楚",
        }
    ],
}

try:
    ArticleDetail.model_validate(bad_data)
except ValidationError as e:
    print("校验失败:")
    for error in e.errors():
        print(error["msg"])

运行:

bash
uv run python main.py

示例会报错,因为评论功能已经关闭,列表里却还有一条评论:

text
校验失败:
Value error, 关闭评论时,评论列表必须为空
python
from pydantic import BaseModel, Field, model_validator

class CommentCreate(BaseModel):
    username: str = Field(min_length=2, max_length=20)
    content: str = Field(min_length=2, max_length=200)

class ArticleDetail(BaseModel):
    title: str = Field(min_length=2, max_length=50)
    content: str = Field(min_length=10)
    summary: str | None = Field(default=None, max_length=100)
    is_published: bool = Field(default=False)
    allow_comments: bool = Field(default=True)
    comments: list[CommentCreate] = Field(default_factory=list)

    @model_validator(mode="after")
    def validate_article_rules(self):
        if self.is_published and not self.summary:
            raise ValueError("文章发布时必须填写摘要")
        if not self.allow_comments and self.comments:
            raise ValueError("关闭评论时,评论列表必须为空")
        return self

新规则对应这一行:

python
if not self.allow_comments and self.comments:

它检查的是 allow_comments 和 comments 的组合,因此同样属于联合校验。

联合规则的组织 ​

规则不满足时,抛出 ValueError:

python
raise ValueError("文章发布时必须填写摘要")

Pydantic 会把这个异常收进 ValidationError。接下来把四条规则放进同一个模型校验器里。

示例里保留了少量 field_validator,用来承接上一章。阅读时重点看 ArticleDetail 中的 model_validator。

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

py
from pydantic import BaseModel, Field, ValidationError, field_validator, model_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)
    summary: str | None = Field(default=None, max_length=100)
    is_published: bool = Field(default=False)
    allow_comments: bool = Field(default=True)
    views: int = Field(default=0, ge=0, le=1_000_000)
    tags: list[str] = Field(default_factory=list)
    author: UserInfo
    comments: list[CommentCreate] = Field(default_factory=list)

    @model_validator(mode="after")
    def validate_article_rules(self):
        if self.is_published and not self.summary:
            raise ValueError("文章发布时必须填写摘要")
        if not self.allow_comments and self.comments:
            raise ValueError("关闭评论时,评论列表必须为空")
        if not self.is_published and self.views > 0:
            raise ValueError("未发布文章的阅读量必须为 0")
        if "精选" in self.tags and not self.is_published:
            raise ValueError("精选文章必须是已发布状态")
        return self


bad_data = {
    "title": "Pydantic 联合校验",
    "content": "这是一篇介绍 model_validator 的文章内容",
    "summary": None,
    "is_published": True,
    "allow_comments": True,
    "views": 10,
    "tags": ["Python"],
    "author": {
        "username": "xiaoming",
        "email": "xiaoming@example.com",
    },
    "comments": [],
}

try:
    article = ArticleDetail.model_validate(bad_data)
    print(article)
except ValidationError as e:
    print("已发布文章缺少摘要:")
    print(e)
    print(e.errors())
    print("错误位置:", e.errors()[0]["loc"])
    print("错误说明:", e.errors()[0]["msg"])


unpublished_views_data = {**bad_data, "is_published": False}
featured_draft_data = {
    **unpublished_views_data,
    "views": 0,
    "tags": ["Python", "精选"],
}

for label, case_data in (
    ("未发布文章有阅读量", unpublished_views_data),
    ("未发布文章带精选标签", featured_draft_data),
):
    try:
        ArticleDetail.model_validate(case_data)
    except ValidationError as e:
        error = e.errors()[0]
        print(label, "错误位置:", error["loc"])
        print("错误说明:", error["msg"])

运行:

bash
uv run python main.py
python
from pydantic import BaseModel, Field, ValidationError, field_validator, model_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)
    summary: str | None = Field(default=None, max_length=100)
    is_published: bool = Field(default=False)
    allow_comments: bool = Field(default=True)
    views: int = Field(default=0, ge=0, le=1_000_000)
    tags: list[str] = Field(default_factory=list)
    author: UserInfo
    comments: list[CommentCreate] = Field(default_factory=list)

    @model_validator(mode="after")
    def validate_article_rules(self):
        if self.is_published and not self.summary:
            raise ValueError("文章发布时必须填写摘要")
        if not self.allow_comments and self.comments:
            raise ValueError("关闭评论时,评论列表必须为空")
        if not self.is_published and self.views > 0:
            raise ValueError("未发布文章的阅读量必须为 0")
        if "精选" in self.tags and not self.is_published:
            raise ValueError("精选文章必须是已发布状态")
        return self

脚本准备了三份数据,每份只违反一条规则,依次触发摘要、阅读量和精选标签的错误。完整输出比较长,这里只列关键部分:

text
已发布文章缺少摘要:
错误位置: ()
错误说明: Value error, 文章发布时必须填写摘要
未发布文章有阅读量 错误位置: ()
错误说明: Value error, 未发布文章的阅读量必须为 0
未发布文章带精选标签 错误位置: ()
错误说明: Value error, 精选文章必须是已发布状态

第二份数据改成未发布,但保留 views=10;第三份再把阅读量清零,并加上 "精选" 标签。这样每次校验都会落到对应的判断。loc=() 表示错误属于整个模型,而不是某一个字段。

读这段代码时,留意下面几处:

跨字段访问

例如:

python
self.is_published
self.summary
self.allow_comments
self.comments
self.views
self.tags

可以同时读取多个字段,是它和 field_validator 最直接的区别。

独立的联合规则

例如:

python
if not self.is_published and self.views > 0:
    raise ValueError("未发布文章的阅读量必须为 0")

这条规则比较发布状态和阅读量,描述的是两者的关系。

模型级错误位置 loc=()

如果你打印 ValidationError.errors(),模型级错误经常会看到:

python
()

这类错误没有对应的单一字段,所以错误位置是空元组 ()。

首条失败规则

按当前这种写法,遇到第一条不满足的规则就会抛出异常:

python
if 条件 1 不满足:
    raise ValueError(...)

if 条件 2 不满足:
    raise ValueError(...)

因此前面的判断失败后,后面的规则不会继续检查。

联合规则结构

跨字段业务规则级联短路判定瀑布

图里按执行顺序串起四条规则,可以用来对照上面的校验器。

联合规则通过示例 ​

再用一份符合全部规则的数据验证模型:

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

py
from pydantic import BaseModel, Field, field_validator, model_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)
    summary: str | None = Field(default=None, max_length=100)
    is_published: bool = Field(default=False)
    allow_comments: bool = Field(default=True)
    views: int = Field(default=0, ge=0, le=1_000_000)
    tags: list[str] = Field(default_factory=list)
    author: UserInfo
    comments: list[CommentCreate] = Field(default_factory=list)

    @model_validator(mode="after")
    def validate_article_rules(self):
        if self.is_published and not self.summary:
            raise ValueError("文章发布时必须填写摘要")
        if not self.allow_comments and self.comments:
            raise ValueError("关闭评论时,评论列表必须为空")
        if not self.is_published and self.views > 0:
            raise ValueError("未发布文章的阅读量必须为 0")
        if "精选" in self.tags and not self.is_published:
            raise ValueError("精选文章必须是已发布状态")
        return self


data = {
    "title": "Pydantic 联合校验",
    "content": "这是一篇介绍 model_validator 的文章内容",
    "summary": "介绍 model_validator 的基本用法",
    "is_published": True,
    "allow_comments": True,
    "views": 10,
    "tags": ["Python", "精选"],
    "author": {
        "username": "  xiaoming  ",
        "email": "xiaoming@example.com",
    },
    "comments": [
        {
            "username": "alice",
            "content": "  写得很清楚  ",
            "likes": 3,
        }
    ],
}

article = ArticleDetail.model_validate(data)
print(article)

运行:

bash
uv run python main.py
python
data = {
    "title": "Pydantic 联合校验",
    "content": "这是一篇介绍 model_validator 的文章内容",
    "summary": "介绍 model_validator 的基本用法",
    "is_published": True,
    "allow_comments": True,
    "views": 10,
    "tags": ["Python", "精选"],
    "author": {
        "username": "  xiaoming  ",
        "email": "xiaoming@example.com",
    },
    "comments": [
        {
            "username": "alice",
            "content": "  写得很清楚  ",
            "likes": 3,
        }
    ],
}

article = ArticleDetail.model_validate(data)

这份数据能通过,因为:

  • 已发布,并且有摘要
  • 允许评论,所以可以有评论列表
  • 已发布,所以 views=10 没问题
  • 标签里有 "精选",同时文章也确实已发布

字段校验器也照常执行。比如:

author.username 和 comments[0].content 两边的空格都会被清理。字段校验器与模型校验器可以同时使用,各自负责不同范围的规则。

文章联合规则综合案例 ​

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

py
from pydantic import BaseModel, Field, ValidationError, field_validator, model_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)
    summary: str | None = Field(default=None, max_length=100)
    is_published: bool = Field(default=False)
    allow_comments: bool = Field(default=True)
    views: int = Field(default=0, ge=0, le=1_000_000)
    tags: list[str] = Field(default_factory=list)
    author: UserInfo
    comments: list[CommentCreate] = Field(default_factory=list)

    @model_validator(mode="after")
    def validate_article_rules(self):
        if self.is_published and not self.summary:
            raise ValueError("文章发布时必须填写摘要")
        if not self.allow_comments and self.comments:
            raise ValueError("关闭评论时,评论列表必须为空")
        if not self.is_published and self.views > 0:
            raise ValueError("未发布文章的阅读量必须为 0")
        if "精选" in self.tags and not self.is_published:
            raise ValueError("精选文章必须是已发布状态")
        return self


good_data = {
    "title": "Pydantic 联合校验",
    "content": "这是一篇介绍 model_validator 的文章内容",
    "summary": "介绍 model_validator 的基本用法",
    "is_published": True,
    "allow_comments": True,
    "views": 10,
    "tags": ["Python", "精选"],
    "author": {
        "username": "  xiaoming  ",
        "email": "xiaoming@example.com",
    },
    "comments": [
        {
            "username": "alice",
            "content": "  写得很清楚  ",
            "likes": 3,
        }
    ],
}

bad_data = {
    "title": "Pydantic 联合校验",
    "content": "这是一篇介绍 model_validator 的文章内容",
    "summary": None,
    "is_published": True,
    "allow_comments": True,
    "views": 10,
    "tags": ["Python"],
    "author": {
        "username": "xiaoming",
        "email": "xiaoming@example.com",
    },
    "comments": [],
}

try:
    article = ArticleDetail.model_validate(good_data)
    print(article.author.username)
    print(article.comments[0].content)
except ValidationError as e:
    print("good_data 校验失败")
    print(e)

try:
    ArticleDetail.model_validate(bad_data)
except ValidationError as e:
    print(e)
    print(e.errors())

运行:

bash
uv run python main.py

运行后可以对照这两种结果:

  1. 正确数据会得到模型对象。
  2. 错误数据会在模型级规则处失败。

本章小结 ​

选校验器时,先看规则依赖哪些字段:只看一个,用 field_validator;要组合多个字段,就用 model_validator。本章使用的 after 模式,在规则通过时返回 self,不通过时抛出 ValueError。