一个接收 webhook 的接口,金额字段有时候是数字,有时候是字符串。接了 Pydantic 之后 "100" 自动变成 100,代码干净了不少。直到有天上游传来订单号 "00123",被转成整数又转回字符串,前导零没了,对账对到半夜。
校验库的隐式转换是把双刃剑。Pydantic v2 在性能和类型系统上都往前走了一步,但它能做什么、不该做什么,比「快了多少」更值得先搞清楚。
v2 变了什么#
最底层的变化是核心用 Rust 重写(pydantic-core),模型的校验逻辑被编译成 schema,之后每次校验复用,不再每次走一遍 Python 反射。这决定了它的性能特征:模型定义一次、校验很多次,才摊得平编译成本。
API 层面,v1 的写法基本都要改:
| v1 | v2 | 备注 |
|---|---|---|
@validator |
@field_validator |
必须配合 @classmethod |
@root_validator |
@model_validator(mode="before"/"after") |
前后两个时机语义不同 |
class Config |
model_config = ConfigDict(...) |
配置项改名,如 orm_mode → from_attributes |
parse_obj / parse_raw |
model_validate / model_validate_json |
行为更一致 |
.dict() / .json() |
model_dump() / model_dump_json() |
支持 mode="json" |
__fields__ |
model_fields |
反射相关的一批改名 |
如果代码里还有 Config 内部类和 @validator,先做机械迁移,再谈优化。混着写会得到很难查的行为差异。
模型怎么写,快在哪里#
下面这个模型把「不许隐式转换」写进了配置,适合边界清晰的内部服务:
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
class Order(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
id: int
amount: int = Field(gt=0, le=10_000_000)
created_at: datetime
tags: list[str] = Field(default_factory=list)
@field_validator("tags")
@classmethod
def dedupe(cls, v: list[str]) -> list[str]:
return list(dict.fromkeys(v)) # 去重但保序
ok = Order(id=1, amount=100, created_at=datetime(2026, 11, 11, 10, 0))
try:
Order(id=1, amount=100, created_at="2026-11-11T10:00:00")
except ValidationError as e:
# strict 模式不把字符串转成 datetime,这里拿到 datetime_type
print(e.errors()[0]["type"])
几个决定值得解释:
strict=True关掉隐式转换,"100"不再变成100。对外接口如果要兼容宽松输入,就不要全局开,改用StrictInt之类的类型逐字段控制。extra="forbid"让多余字段直接报错。默认是忽略,这在接收第三方 payload 时会让你以为字段收到了,其实被悄悄丢掉。Field(gt=0)这类约束在 schema 层,比在校验器里手写if快,错误信息也更规整。
需要校验的不是模型、而是一个列表或联合类型时,用 TypeAdapter:
from pydantic import TypeAdapter
orders = TypeAdapter(list[Order]).validate_python(payload) # payload 是 dict 列表
TypeAdapter 复用同一份编译后的 schema,比临时造一个包装模型更轻。
「快」该怎么理解#
Pydantic v2 比 v1 快,这一点没有争议;但具体倍数取决于模型结构、字段数量和输入类型,不要拿别人的 benchmark 数字当预期。更靠谱的做法是在自己的 payload 上量:
import timeit
from datetime import datetime
from pydantic import TypeAdapter
raw = {
"id": 1,
"amount": 100,
"created_at": datetime(2026, 11, 11, 10, 0),
"tags": ["a", "a"],
}
adapter = TypeAdapter(Order)
adapter.validate_python(raw) # 预热:编译 schema、填缓存
print(timeit.timeit(lambda: adapter.validate_python(raw), number=100_000))
三个注意点:
- 一定要预热。第一次校验包含构建成本,直接计时会得到离谱的数字。
- 用真实 payload,不要用一个字段的玩具模型。
- 对比时把「不校验」的基线也跑一遍,比如
Order.model_construct(**raw),它能告诉你校验本身占了多少。
如果校验耗时在整体链路里不到百分之一,优化它没有意义。真正慢的通常是网络、序列化和数据库。
校验不是业务规则#
这是最容易越界的地方。类型、格式、范围放模型;跨实体的判断放服务层。
- 「金额必须大于 0」——模型层,
Field(gt=0)。 - 「用户余额必须够」——服务层,模型不该查数据库。
- 「这个订单状态不能取消」——服务层,状态机归业务。
- 「A 字段为空时 B 字段必填」——可以放模型,用
model_validator(mode="after"),但错误信息要能指到具体字段。
把数据库查询写进模型校验器,短期省几行代码,长期会让模型无法在测试里独立构造,也没法在批量导入时复用。
什么时候它特别值#
- HTTP 边界:请求体、查询参数、响应模型,一份定义三个地方复用,还能直接生成 OpenAPI 文档。
- 配置加载:
pydantic-settings读环境变量,字段缺失或类型不对在启动时就报错,而不是跑到半夜才发现某个开关是字符串"false"。 - 消息队列消费:坏消息在进入业务逻辑之前就被拦下,进死信队列,不用在每段代码里
try/except KeyError。 - 结构化输出解析:把模型当 schema 用,解析失败可以带着错误信息重试,比用正则抠 JSON 稳得多。
- 批量导入:一次收集所有行的错误再统一反馈,而不是遇到第一条就中断。
两种情况下值得看看别的方案:吞吐极高、每行都要校验上百万次的场景,可以评估 msgspec 这类更薄的库;只是打包数据、不需要转换和校验时,dataclass 更省事。
几个具体的坑#
- 联合类型逐个试。
Union[A, B, C]会按顺序尝试,全部失败才报错,字段一多就慢。结构可区分时用Field(discriminator="type"),直接命中。 - 递归模型。自引用模型在 v2 里可能需要显式
model_rebuild(),尤其是定义顺序有交叉时。 - 序列化要指定模式。
model_dump()保留 Python 对象(datetime、Decimal),写 JSON 响应时用model_dump(mode="json"),否则序列化器会二次处理。 - 错误信息定位。
e.errors()返回的loc是从模型根开始的路径,嵌套模型要拼起来给前端,别只取最后一段。 from_attributes。从 ORM 对象构造要开这个配置,v1 的orm_mode已经不存在了。- 循环导入。模型互相引用时把公共类型抽到单独模块,别靠延迟导入硬撑。
Optional不等于有默认值。x: int | None仍然要求字段出现,想让它可选必须写= None。这点 v1 也一样,但迁移时最常被忘。- 校验器顺序会改变结果。同一字段的多个
field_validator按定义顺序执行,mode="before"早于类型转换,mode="after"晚于它。挪一个装饰器位置,行为就可能不同。
结论#
- 先完成 v1→v2 的机械迁移,再谈优化,别混用两套写法。
- 对外边界用
strict=True+extra="forbid",需要宽松兼容时逐字段放开。 - 性能在真实 payload 上量,预热后跑十万次,并与
model_construct的基线对比。 - 模型只管结构与格式,跨实体规则留给服务层。
- 联合类型用判别器,序列化用
mode="json",这两条能省掉大量调试时间。 - 迁移可以借助社区的自动改写脚本(例如
bump-pydantic),但它生成的结果仍要人工过一遍,尤其是校验器语义和Optional字段。
完