第 8 章 model_validator 跨字段校验
前面几章我们处理过类型、Field 约束,也给单个字段加过自定义校验。接下来要解决的情况更常见,也更容易放错位置:一条规则同时依赖几个字段。
比如:
- 文章已发布,就必须填写摘要
- 关闭评论后,评论列表必须为空
- 未发布文章的阅读量必须为
0 - 标签里有
"精选",文章必须已发布
要判断它们是否成立,光看某一个字段都不够。本章会用 model_validator 把这类规则放到模型层处理。
本章学习目标
规则只看一个字段,还是得把几个字段放在一起判断?先分清 field_validator 和 model_validator 的职责,再用 @model_validator(mode="after") 写联合校验。你会通过 self.xxx 读取字段,规则不满足时抛出 ValueError,并把多字段规则整理得容易阅读和讲解。
模型级校验规则
这一章还是围绕文章详情模型展开。先把要用到的字段列出来:
summary -> 文章摘要
is_published -> 是否发布
allow_comments -> 是否允许评论
comments -> 评论列表
views -> 阅读量
tags -> 标签列表然后用它们表达四条规则:
规则 1:已发布文章必须有摘要
规则 2:关闭评论时,评论列表必须为空
规则 3:未发布文章阅读量必须为 0
规则 4:标签包含“精选”时,文章必须已发布四条规则都要比较字段之间的状态,所以不适合塞进某个字段自己的校验器。下面每一步都提供完整示例,可以复制到 .code/main.py 运行;进入下一步时再用新示例覆盖它。
已发布文章摘要规则
先用上一章的用户名校验做个对照。field_validator 只负责处理 username:
例如:
@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 里。发布状态和摘要之间的关系,则需要模型级校验:
例如:
@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 模式:
@model_validator(mode="after")它会在字段完成校验后运行。因此进入方法时,可以直接读取已校验的字段值,例如:
self.summary
self.comments
self.tags字段校验与模型校验的分工
记这个分工就够用:field_validator 处理一个字段,model_validator 处理字段之间的关系。
模型校验执行流程
这一章只讲 mode="after",它的执行顺序可以看成下面这样:
图里有三个关键节点:
before字段校验器先处理原始输入,after字段校验器在类型与Field规则之后执行。- 只有字段校验通过,才会进入
model_validator(mode="after")。 - 所以这里适合写“多个字段之间的规则”。
先看最小例子,只校验发布状态和摘要。
将下面的完整代码保存为 .code/main.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"])运行:
uv run python main.py这次会因为文章已发布、但没有摘要而失败:
校验失败:
Value error, 文章发布时必须填写摘要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核心判断在这里:
if self.is_published and not self.summary:条件同时读取两个字段,因此放进 model_validator。
对应关系可以看成这样:
如果传入下面这份数据:
bad_data = {
"title": "Pydantic 联合校验",
"content": "这是一篇介绍 model_validator 的文章内容",
"is_published": True,
}就会因为“已发布但缺少摘要”而失败。
评论开关与评论列表
mode="after" 校验通过后要返回模型对象,也就是 self:
return self和字段校验器对照着记:
field_validator 通过后 return value
model_validator 通过后 return self现在给模型加上评论开关和评论列表,再看一条联合规则:
将下面的完整代码保存为 .code/main.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"])运行:
uv run python main.py示例会报错,因为评论功能已经关闭,列表里却还有一条评论:
校验失败:
Value error, 关闭评论时,评论列表必须为空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新规则对应这一行:
if not self.allow_comments and self.comments:它检查的是 allow_comments 和 comments 的组合,因此同样属于联合校验。
联合规则的组织
规则不满足时,抛出 ValueError:
raise ValueError("文章发布时必须填写摘要")Pydantic 会把这个异常收进 ValidationError。接下来把四条规则放进同一个模型校验器里。
示例里保留了少量 field_validator,用来承接上一章。阅读时重点看 ArticleDetail 中的 model_validator。
将下面的完整代码保存为 .code/main.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"])运行:
uv run python main.pyfrom 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脚本准备了三份数据,每份只违反一条规则,依次触发摘要、阅读量和精选标签的错误。完整输出比较长,这里只列关键部分:
已发布文章缺少摘要:
错误位置: ()
错误说明: Value error, 文章发布时必须填写摘要
未发布文章有阅读量 错误位置: ()
错误说明: Value error, 未发布文章的阅读量必须为 0
未发布文章带精选标签 错误位置: ()
错误说明: Value error, 精选文章必须是已发布状态第二份数据改成未发布,但保留 views=10;第三份再把阅读量清零,并加上 "精选" 标签。这样每次校验都会落到对应的判断。loc=() 表示错误属于整个模型,而不是某一个字段。
读这段代码时,留意下面几处:
跨字段访问
例如:
self.is_published
self.summary
self.allow_comments
self.comments
self.views
self.tags可以同时读取多个字段,是它和 field_validator 最直接的区别。
独立的联合规则
例如:
if not self.is_published and self.views > 0:
raise ValueError("未发布文章的阅读量必须为 0")这条规则比较发布状态和阅读量,描述的是两者的关系。
模型级错误位置 loc=()
如果你打印 ValidationError.errors(),模型级错误经常会看到:
()这类错误没有对应的单一字段,所以错误位置是空元组 ()。
首条失败规则
按当前这种写法,遇到第一条不满足的规则就会抛出异常:
if 条件 1 不满足:
raise ValueError(...)
if 条件 2 不满足:
raise ValueError(...)因此前面的判断失败后,后面的规则不会继续检查。
联合规则结构
图里按执行顺序串起四条规则,可以用来对照上面的校验器。
联合规则通过示例
再用一份符合全部规则的数据验证模型:
将下面的完整代码保存为 .code/main.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)运行:
uv run python main.pydata = {
"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:
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())运行:
uv run python main.py运行后可以对照这两种结果:
- 正确数据会得到模型对象。
- 错误数据会在模型级规则处失败。
本章小结
选校验器时,先看规则依赖哪些字段:只看一个,用 field_validator;要组合多个字段,就用 model_validator。本章使用的 after 模式,在规则通过时返回 self,不通过时抛出 ValueError。