1. 为什么凡是写 Python 的人都该学一下 Pydantic
做 Python 开发这些年,我有个特别深的感受:写代码最费时间的往往不是业务逻辑本身,而是“数据进来之前你根本不知道它长什么样”。你调用一个第三方接口,对方返回的字段一会儿有一会儿没有,类型一会儿是字符串一会儿是数字;你读一个配置文件,少写一个 key 程序直接崩溃;你从 Redis 里取出一坨 JSON,想当然地data["user"]["age"] + 1,结果 age 是个字符串,直接 TypeError。这些问题说到底,就是数据校验和类型安全没做好。
Pydantic 就是专门解决这个痛点的 Python 库。它做的事情非常朴素:用你定义的模型类来自动校验、清洗和转换外部数据。你在类里声明“age 必须是 int,name 必须是 str”,Pydantic 就会在数据进入的那一刻帮你检查,不对就报错,能转的就自动转。它不仅是 FastAPI 御用的数据层(FastAPI 的请求参数校验、响应序列化底层全靠它),也完全可以独立用在你的脚本、爬虫、配置文件解析、数据处理管道里。
这篇内容我会完全从基础用法讲起,覆盖模型定义、字段类型、默认值与必填、校验器、嵌套模型、别名与配置这些最核心的知识点,并且会把我在实际项目里踩过的坑一起写出来。不管你是刚接触 Pydantic 的初学者,还是写了一阵子但总感觉“哪里不太对”的人,这篇文章应该都能帮到你。我不打算写成一堆文档的拼凑,更想以“一个用了一年多 Pydantic 的老工程师”的视角,把这些东西讲透。
2. 核心设计思路:为什么“声明式”比“写 if 判断”优雅得多
2.1 一个例子看懂 Pydantic 想让你干什么
先看一个最常见的场景。假设你要接一个用户注册的接口,前端传过来的 JSON 长这样:
{ "name": "张三", "age": "28", "email": "zhangsan@example.com", "tags": ["python", "backend"] }注意一个细节:前端把age传成了字符串"28"。按照老写法,你得这么做:
def validate_user(data): if "name" not in data or not isinstance(data["name"], str): raise ValueError("name 必须是字符串") if "age" not in data: raise ValueError("age 必填") try: age = int(data["age"]) except (TypeError, ValueError): raise ValueError("age 必须是数字") if age < 0 or age > 150: raise ValueError("age 不在合理范围") ...写三五个字段还能忍,写二三十个字段的时候这个函数会膨胀到没法看,而且每个接口的数据结构还不一样,你得为每个接口写一套这样的校验逻辑。更麻烦的是,这种校验逻辑和你的业务代码混在一起,出错了报错信息也不统一。
用 Pydantic 就简单得多。你先定义一个模型,描述清楚“数据长什么样”:
from pydantic import BaseModel class User(BaseModel): name: str age: int email: str tags: list[str] = []然后你只需要把数据丢进去:
user = User(name="张三", age="28", email="zhangsan@example.com", tags=["python", "backend"]) print(user.age) # 28 print(type(user.age)) # <class 'int'>Pydantic 自动帮你把字符串"28"转成了 int,还验证了必填字段是否齐全。如果传入的 age 是"abc",它会抛出一个非常清晰的ValidationError,告诉你 age 这一字段“input should be a valid integer”。这就是 Pydantic 的核心价值:你不用再写一大堆 if-else 判断,你只需要声明数据的“形状”,剩下的交给框架。
2.2 Pydantic 与 dataclass 的本质区别
很多人第一次接触 Pydantic,觉得它不就是dataclasses.dataclass的增强版吗?这个说法对了一半。dataclass 确实帮你省掉了__init__的重复劳动,但它不做任何类型校验,只做类型提示。换句话说:
from dataclasses import dataclass @dataclass class UserDC: age: int user = UserDC(age="28") print(user.age) # "28" 字符串,程序不报错注意,类型标注age: int只是给 IDE 和阅读代码的人看的,Python 解释器根本不管你是不是真的传了 int。等你后续写user.age + 1的时候,直接在运行时炸掉。而 Pydantic 在对象创建的那一刻就完成了校验和转换,你在整个生命周期里拿到的是一个“干净的、可信赖的数据对象”。用生活化的话说:dataclass 只是给变量贴了个标签,Pydantic 则是在门口安排了一个保安,进来的每件货物都必须符合标准,不符合直接拒收。
2.3 为什么声明式校验更适合现代应用开发
现代应用开发面对的数据来源太杂了:前端表单、第三方 API、数据库记录、消息队列、配置文件、Excel 导入……这些数据没一个是“完全可信”的。声明式校验带来的最大好处是把“数据可信化”这一步集中化、标准化。你在边界处(API 入口、配置加载处)把这些数据全部转成 Pydantic 模型,内部的业务代码操作的全是类型确定、字段确定的对象,心智负担大幅降低。
另外,Pydantic 的模型本身就是一个“文档”。你定义了一个User(BaseModel),别人看你的代码一眼就能知道:“这个接口需要 name、age、email,tags 可选”。这比散落在各处的 if 判断要清晰得多,也方便通过model_json_schema()直接生成 OpenAPI 文档给前端同学看。
3. 字段类型、必填与默认值:这些细节别等踩坑才学
3.1 字段类型标注:比你想的更“智能”
Pydantic v2 里你可以直接用 Python 标准类型做注解,它内置了一套完整的校验与转换逻辑。最常用的几类:
- 基础类型:
int、float、str、bool、bytes - 容器类型:
list、dict、set、tuple,可以配合子类型如list[int] - 特殊类型:
Optional[int]表示“可以是 int 也可以为 None”,Union[int, str]表示“两者之一” - 枚举类型:
Enum子类,用来限定字段的取值范围
这里有一个非常容易踩坑的点:bool的转换规则。Pydantic v2 里,字符串"yes"、"on"、"1"这些是不会自动转换成True的,只有"true"、"True"、"1"(数字 1)会被转换。我早先用 Pydantic 解析一套老系统的配置,里面用"yes"表示开启,结果模型字段的 bool 类型一直报校验错误。后来查文档才发现,bool的严格校验和宽松校验行为不一样。如果你确实需要兼容"yes"/"no",建议自定义BeforeValidator做一层预处理。
再说Optional。Optional[int]的语义是“这个字段可以缺省,缺省时值为 None,也可以显式传入 None”。但它和“缺省默认值”是两个概念:
class A(BaseModel): a: int # 必填 b: Optional[int] # 必填(但这个字段允许值为 None) c: int = None # 这样写会报错!int 类型不能赋 None看上面这个例子,很多新手会困惑:b: Optional[int]不是“可选字段”吗?为什么说它必填?Pydantic 的“可选”只表示类型可以包含 None,不代表这个字段可以不出现在输入数据里。如果你想让这个字段“可以不传,不传就用默认值”,必须同时给默认值:
class A(BaseModel): b: Optional[int] = None # 这才是真正的“可选字段”3.2 默认值与 Field 的进阶用法
直接b: int = 0可以给 int 类型设置默认值。但更多时候你需要的不是简单的默认值,而是“动态默认值”或者“带约束的默认值”。这时候用Field函数:
from pydantic import Field class Product(BaseModel): name: str price: float = Field(gt=0, description="价格必须大于0") stock: int = Field(default=0, ge=0, le=10000) created_at: datetime = Field(default_factory=datetime.now)几个关键点:
Field(gt=0)表示大于 0,类似的约束还有ge(大于等于)、lt(小于)、le(小于等于)、min_length、max_length、pattern(正则匹配)default_factory接收一个零参函数,每次创建实例时调用它生成默认值。datetime.now不能直接写成Field(default=datetime.now),因为那样的话所有实例会共用同一个创建时刻的值,会出现“所有对象的时间都一样”的诡异 bug。我见过不少人在生产环境踩这个坑:模型实例明明创建时间不同,created_at却一模一样,查了半天最后发现就是default=datetime.now的问题。Field里的description不仅起到注释作用,还会被model_json_schema()输出到 JSON Schema 中,如果项目有自动生成 API 文档的需求,这个字段非常有用。
3.3 枚举字段:让非法值无处可逃
业务里经常会遇到“类型只有几种固定取值”的字段,比如订单状态pending、paid、shipped、cancelled。最朴素的做法是文档里写“请传这四个值之一”,然后靠运行时 if 判断。用 Pydantic 枚举字段的话,你可以在类型系统层面就锁死范围:
from enum import Enum class OrderStatus(str, Enum): PENDING = "pending" PAID = "paid" SHIPPED = "shipped" CANCELLED = "cancelled" class Order(BaseModel): order_id: str status: OrderStatus这里有个小技巧:继承str, Enum而不是只继承Enum,这样枚举值本身就是字符串,可以直接和 JSON 序列化兼容。如果你只继承Enum,在 Pydantic v1 里输出到 JSON 时会遇到枚举类型无法序列化的问题;v2 虽然好一些,但str枚举在很多场景下更方便。
当传入一个不存在的状态时,Pydantic 抛出的ValidationError会明确告诉你 “Input should be 'pending', 'paid', 'shipped' or 'cancelled'”,使用者一眼就能看出问题出在哪,不用自己去猜。
4. 必会的三个操作方法:模型校验、导出、解析
4.1 三种实例化方式:直接传参、parse_obj、model_validate
Pydantic v2 中,创建模型实例最推荐的方式就是直接调用类构造器,因为类型检查和转换都发生在构造阶段。除此之外还有几个相关方法,它们的区别值得理清:
User(name="张三", age=20):标准构造方式,会触发校验与转换。User.model_validate(data_dict):传入一个 dict 或任意对象,对其进行解析校验。如果你要从外部 API 的 JSON 响应、数据库查询结果里构建模型,这个方法最常用。User.model_validate_json(json_str):直接传 JSON 字符串,内部先做 JSON 反序列化再校验。
实际上在 Pydantic v2 里,User(**data)和User.model_validate(data)结果几乎一致,推荐后者的原因是语义更清晰:我们要把一个“类字典”的数据源解析成模型。看个实际例子,从数据库取出一行记录:
row = {"name": "李四", "age": "30", "tags": ["web"]} user = User.model_validate(row)DB 里存的 age 可能是字符串或者与模型声明不完全一致,model_validate会帮你统一转换成模型里定义的类型。
4.2 导出模型:dict()、model_dump() 与 JSON 序列化
模型是拿来做事的,往往还需要再导出去。Pydantic v1 里的obj.dict()和obj.json()在 v2 中已分别演进为obj.model_dump()和obj.model_dump_json(),不过旧方法仍保留了兼容性。我的建议是:新项目全部用新 API,别再纠结过去。
user = User(name="张三", age=28, tags=["python"]) # 转成 dict data = user.model_dump() # {'name': '张三', 'age': 28, 'tags': ['python']} # 转成 JSON 字符串 json_str = user.model_dump_json() # '{"name":"张三","age":28,"tags":["python"]}' # 指定只导出某些字段 partial = user.model_dump(include={"name", "age"}) # 排除某些字段 without_tags = user.model_dump(exclude={"tags"})include和exclude都支持嵌套,比如exclude={"user": {"password"}},在给前端返回脱敏数据时非常有用。这是个细节能力:我经常用它来做日志输出,避免把敏感字段(如 token、密码)打在日志里。
4.3 校验失败时的异常结构与捕获技巧
Pydantic 校验失败时抛的是pydantic.ValidationError,这个异常对象里有一个errors()方法,返回一个列表,每个元素对应一个错误的具体信息:
from pydantic import ValidationError try: User(name="张三", age="abc") except ValidationError as e: print(e.errors()) # [{'type': 'int_parsing', 'loc': ('age',), 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'abc', 'url': 'https://errors.pydantic.dev/...'}]loc是错误位置,msg是给人类读的信息,type是错误类型(如int_parsing、missing、extra_forbidden)。在实际项目中,你可以根据type字段做逻辑分支,比如它是missing就返回“字段缺失”的提示,是extra_forbidden就返回“多传了不允许的字段”。这种精细化的错误处理在 API 层尤为重要——你不能让用户看到一个英文的、冗长的默认提示,而是应该转成自己的业务错误码。
5. 配置与别名:让模型适配真实世界的“脏数据”
5.1 为什么需要 alias 而不是改字段名
真实世界的数据往往带着历史包袱,最典型的就是:PHP 和 JavaScript 那边习惯用userName、created_at这样的命名,Python 这边习惯用user_name、createdAt。如果你为了适配前端而把 Python 代码里的变量名改成userName,那你的代码风格会变得不伦不类,而且你的 IDE 自动补全、静态检查都会受到影响。
Pydantic 的alias就是用来解决“外部字段名”和“内部字段名”不一致的问题。你可以保持 Python 侧的字段名为user_name,同时声明别名userName:
from pydantic import BaseModel, Field, ConfigDict class User(BaseModel): model_config = ConfigDict(populate_by_name=True) user_name: str = Field(alias="userName") age: int然后两种方式都能解析:
# 外部数据用别名 user1 = User.model_validate({"userName": "王五", "age": 25}) # 自己代码里用原生字段名 user2 = User(user_name="赵六", age=26)这里的关键配置是populate_by_name=True,它允许你在构造时既可以使用别名字段名,也可以使用原始字段名。如果不设置这一步,直接User(user_name="赵六")会报错——因为它会去找别名字段userName而不是user_name,这个坑我已经见了太多人踩。
5.2 model_config 里那些实用开关
除了alias,模型配置里还有几个特别常用的设置:
| 配置项 | 作用 | 建议 |
|---|---|---|
extra='ignore' | 忽略所有未在模型中定义的字段 | 默认行为,适合大多数场景 |
extra='forbid' | 只要出现未定义字段就报错 | 严格模式,适合安全要求高的场景 |
extra='allow' | 允许额外字段,存到model_extra中 | 做数据透传时有用 |
populate_by_name=True | 允许同时用字段名和别名构造 | 建议开启 |
str_strip_whitespace=True | 自动去掉字符串首尾空白 | 表单类数据强烈建议开 |
validate_assignment=True | 实例属性被重新赋值时也触发校验 | 需保证数据全程不变,可开启 |
extra='forbid'我在一个支付回调项目里用过。原因很简单:支付回调的参数是别人传给你的,多一个字段不一定是好事,可能是对方加了新参数而你还没适配,也可能是有人恶意构造请求。直接 forbid 就能让这种请求在入口处失败,输出清晰的错误信息,不必等业务代码跑起来才发现问题。
5.3 v2 的 Config 变化简述
如果你是 Pydantic v1 的老用户,v2 里最明显的改动就是:class Config这种老写法虽然还能用,但推荐写法变成了在类里直接定义model_config = ConfigDict(...)。这两者在功能上等价,只是风格不同。新项目一律按 v2 风格写就好,务必注意你的 Pydantic 版本,不同版本的 API 差异不小,网上搜到的很多代码片段是 v1 的,直接复制到 v2 会报错。
6. 嵌套模型与复杂结构:真实数据几乎不会只有一层
6.1 嵌套模型:从“一维结构”走向“树形结构”
真实业务里的数据很少是扁平的。一个订单里嵌套了用户信息、商品列表、地址信息,这在 JSON 里就是一层套一层的结构。Pydantic 天然支持嵌套模型,你只需要在字段类型里写上另一个模型类:
class Address(BaseModel): city: str street: str class UserProfile(BaseModel): name: str address: Address # 嵌套模型传数据的时候,address可以传一个 dict(Pydantic 会自动转换成 Address 实例),也可以传一个现成的 Address 对象:
data = {"name": "小明", "address": {"city": "北京", "street": "中关村大街"}} profile = UserProfile.model_validate(data) print(profile.address.city) # 北京 # 或者直接传 Address 实例 profile2 = UserProfile(name="小红", address=Address(city="上海", street="南京路"))嵌套模型的最大价值在于结构即文档。你查看UserProfile.model_json_schema()时,会看到一棵完整的 JSON 结构树,前端同学可以直接拿它去生成 TypeScript 类型定义,开发效率提升非常明显。
6.2 List、Dict、Union 的组合使用
容器类型组合嵌套模型是最常用的姿势。比如一个订单包含多个商品:
class OrderItem(BaseModel): sku: str quantity: int = Field(gt=0) price: float = Field(ge=0) class Order(BaseModel): order_no: str items: list[OrderItem]Pydantic 会自动把items里的每个 dict 转换为OrderItem实例。如果你传了items=[{"sku": "A1", "quantity": -1, "price": 9.9}],会因为quantity <= 0触发校验错误,而且错误定位会精确到items.0.quantity这一层。这对于定位复杂嵌套结构里的问题太有用了,不然在一个几百行的 JSON 里找“哪个字段出错”会非常痛苦。
再来说Union。v2 里Union[int, str]表示既可以是 int 也可以是 str;Optional[int]其实等价于Union[int, None]。使用Union要留意:当多个类型都满足条件时,Pydantic 会按顺序尝试。我遇到过把Union[int, str]写成Union[str, int]后数字全被转成字符串的情况,因为 str 在前先被接受了。如果你想让“数字必须是真正的 int”,把 int 放前面就行;如果字段的设计本来就是“可以传数字也可以传字符串数字”,那你需要想清楚先尝试哪个类型更符合业务预期。
6.3 嵌套模型时的“深度校验”策略:模型级别 vs 字段级别
刚才看到的都是“字段级别”的校验,比如quantity > 0。但有些规则是跨字段的:比如“折扣不能大于总价”、“结束时间不能早于开始时间”。这类约束放在单个字段上做不到,需要在整个模型层面上校验。Pydantic v2 提供了model_validator这个利器:
from pydantic import model_validator class Booking(BaseModel): start: datetime end: datetime @model_validator(mode="after") def check_time_range(self): if self.end <= self.start: raise ValueError("结束时间必须晚于开始时间") return selfmode="after"表示在字段解析完成后执行,此时self.start和self.end都已经是真正的 datetime 对象,你可以做复杂的跨字段比较。这是我在做预订、排期类业务时的高频用法。类似的,还有mode="before",它在解析之前执行,常用于清洗预处理、把复杂结构拆解等场景。
7. 自定义校验器与类型:把规则内聚到模型里
7.1 field_validator:单字段的精细化校验
Field自带的约束能满足 80% 的单字段需求,但总有剩下 20% 的规则它表达不了。比如“用户名不能全数字”“手机号必须符合 11 位数字”这类业务规则,就需要自定义校验器。v2 的写法是field_validator:
from pydantic import field_validator class User(BaseModel): name: str phone: str @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: v = v.strip() if v.isdigit(): raise ValueError("用户名不能是纯数字") return v @field_validator("phone") @classmethod def validate_phone(cls, v: str) -> str: if not v.isdigit() or len(v) != 11: raise ValueError("手机号必须是11位数字") return v这里有两个细节值得注意。第一,校验器必须声明为@classmethod,第一个参数是cls,原因在于 Pydantic 内部对校验器的调用方式类似类方法;第二,校验器可以返回一个修改后的值,比如上面我先做了strip()再返回,这样模型里存的就是清洗后的数据。
7.2 BeforeValidator 和 AfterValidator:数据清洗的两道工序
除了field_validator默认的“字段解析后校验”,你还可以通过BeforeValidator在解析前对原始值做预处理。比如前端可能传"12345678901"或者" 123-4567-8901 ",你要统一手机号格式,可以在解析之前先做清洗:
from typing import Annotated from pydantic import BeforeValidator def normalize_phone(v): if isinstance(v, str): v = v.replace("-", "").replace(" ", "") return v Phone = Annotated[str, BeforeValidator(normalize_phone)] class User(BaseModel): phone: Phone这个思路可以推广到很多“脏数据”场景:把时间字符串统一、把金额里的逗号去掉、把全角数字转半角……这些预处理逻辑在BeforeValidator里做掉之后,后续的解析就不用再面对乱七八糟的输入了。
7.3 自定义类型:用 Annotated 组合出可复用的“规则包”
如果你有一个规则在很多模型里都会用到,每次都写field_validator太啰嗦。Pydantic 的Annotated类型系统允许你把规则组合成一个新的类型别名:
from pydantic import AfterValidator, StringConstraints from typing import Annotated NonEmptyStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)] class Article(BaseModel): title: NonEmptyStr content: NonEmptyStr这比在每个字段上重复写Field(min_length=1)要简洁得多,也更符合“单一职责”的思想。你要是维护过大型项目就会发现,业务里反复出现的“非空字符串”“规范化手机号”“合法金额”这类类型,能抽象成公共类型会让模型定义干净不少。
8. 序列化与 ORM 场景:从数据库到接口的一体化方案
8.1 把 Pydantic 模型变成可 JSON 序列化的数据
model_dump_json()已经帮你处理了绝大多数常见类型的序列化,包括 datetime、UUID、Enum 等。有个容易忽略的细节是:datetime默认序列化格式是 ISO8601,例如"2024-06-01T12:00:00+08:00"。如果你的前端希望看到"2024-06-01 12:00:00"这种格式,就需要自定义序列化器,或者在后端事先把 datetime 转成字符串。我在一个内部系统里就见过,前端代码解析 ISO 格式时报错,因为对方用的是老旧的 JavaScript 解析库,对时区偏移支持不好。这种“两边定义不一致”的问题,最好在模型层就把格式约定好。
8.2 和 SQLAlchemy ORM 搭配的基本姿势
Pydantic 经常被用于 API 层的数据校验,而 ORM(比如 SQLAlchemy)负责数据库映射。两者之间的配合有两种常见姿势:
其一,把 ORM 实例的数据提取成字典,再用 Pydantic 模型校验:
user_orm = session.query(UserORM).first() user_dict = { "id": user_orm.id, "name": user_orm.name, "age": user_orm.age, } user_schema = UserOut.model_validate(user_dict)这种做法适合简单场景,缺点是当 ORM 类字段很多时,手动组 dict 很繁琐。更好用的是model_config = ConfigDict(from_attributes=True),它允许直接对 ORM 实例做校验:
class UserOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int name: str age: int user_schema = UserOut.model_validate(user_orm) # 直接从 ORM 实例读取属性这个开关意味着 Pydantic 会尝试从源对象的属性中取值,而不只是要求 dict。配合 FastAPI 的response_model使用,返回 ORM 对象时它会自动序列化为 Pydantic 模型,非常丝滑。需要注意:from_attributes=True只影响从对象取值,不代表安全或者“避开校验”,字段类型校验依然生效。
8.3 大模型数据的性能:v2 的 Rust 内核带来的提升
Pydantic v2 底层核心完全用 Rust 重写(pydantic-core),解析速度比 v1 提升了好几倍,内存占用也大幅下降。这意味着在数据量较大的场景,比如一次处理上万条记录,v2 的性能表现要好得多。我在一次批量导入的脚本里,用 v2 跑十万条数据,解析时长从 v1 的 8 秒多降到了 2 秒左右。如果你维护的老项目还在用 v1,且性能瓶颈出现在数据校验环节,升级 v2 的收益会非常大。当然升级过程需要留意 API 变动(后面会专门说),但对于新项目,直接用 v2 是明确的选择。
9. 常见报错与排查技巧:这些坑我基本都踩过
9.1 ValidationError 不显示具体哪个字段
有时候你写User.model_validate(data),抛出的 ValidationError 会很长,尤其嵌套复杂结构时,错误列表里会有很多条目。建议配合str(e)或e.errors()来看具体位置。还有一个小技巧:如果错误太多,可以通过e.errors()取前几个错误先处理,不用一次暴露全部,避免用户或日志被一大堆错误刷屏。
9.2 为什么传None也报错
如果你定义age: int,传入None会报int_parsing错误。这是预期行为,int类型不接受 None 值。如果你确实想让某个字段允许为空,应该用Optional[int]或int | None(Python 3.10+)。我在项目中见过不少人把“可空”和“可选”混为一谈,最后导致用户少传一个值时明明应该正常,却不断报错,非常影响体验。
9.3 字符串数字和布尔值的“惊喜”
前面说过的 bool 转换问题再补充一个:在宽松模式下,"false"这个字符串会怎么转换?v2 中"false"不会自动转成False,它会留在字符串校验失败。原因在于宽松模式只尝试解析几种“常见真值”,"false"不在其中。如果你对接的系统会用"true"/"false"字符串传递布尔值,别指望默认转换,一定写个BeforeValidator把字符串映射到 bool。
字段注解用str | None还是Optional[str]?两者语义完全相同,看团队代码风格保持一致即可。我个人偏好用Optional[str],因为在老代码里混着from typing import Optional已经成习惯了,新项目也可以直接用str | None,更简洁。这种风格问题最好在项目基础代码里统一,不要一个文件一个风格。
9.4 v1 迁移到 v2 的常见兼容问题
obj.dict()→obj.model_dump()obj.json()→obj.model_dump_json()obj.parse_obj(data)→obj.model_validate(data)obj.parse_raw(json_str)→obj.model_validate_json(json_str)class Config→model_config = ConfigDict(...)@validator→@field_validator,且必须声明为@classmethod@root_validator→@model_validator(mode="after")或mode="before".schema()→.model_json_schema()
如果你是被迫迁移的老项目,可以用pydantic.v1子模块来过渡,但这不是长久之计。与其用兼容层,不如抽一天时间把代码里的 Pydantic 调用全部过一遍,然后统一升级。另外 v2 的校验规则在某些边界行为上也变了(比如bool的宽松转换),单写单元测试很难覆盖全面,建议在迁移前后各跑一遍完整的测试套件,对比差异。
9.5 性能相关的两个提醒
第一,频繁构造相同结构的模型是有开销的。如果你在一个大循环里对每条记录都做User.model_validate(...),性能会有可见损耗。能批量处理时,最好尽量批量;或者考虑把解析放到数据边界处做一次,而不是每一行都做。第二,Pydantic v2 虽然已经很快,但字段很多且嵌套很深时,性能仍然不如手写简单字典操作。如果你的场景是“千万级数据的快速清洗”,不一定非要每个数据都转成模型,有时候直接用原始字典做轻量处理反而更合适。工具始终要服务于业务,不要为了用 Pydantic 而用 Pydantic。
10. 从基础用法到工程化落地:我的真实实践心得
纯粹知道 API 怎么调用和真正用好 Pydantic 之间,还有一段不小的距离。最后我想分享几个自己在实际工程里沉淀下来的体会。
第一,把 Pydantic 用在数据边界,不要侵入全部业务代码。我在项目里一般只在三处使用:API 的请求参数校验(FastAPI 帮你做了)、外部数据源进入系统的入口(RPC 调用、MQ 消费、第三方回调)、配置文件加载。把模型当作“边界守卫者”,而内部业务代码操作的就是已经被验证过的对象。这样做的好处是职责清晰,模型层不会变成到处传递的“万能对象”。
第二,尽量把校验规则定义在模型层,而不是到处散落的 if。遇到“这个字段要符合某某格式”,先停下来想一想:能不能把它写进 Pydantic 模型的 validator 里?能不能抽象成一个 Annotated 类型?如果能够,后续任何入口、任何人调用这个模型都会自动获得同样的保护,而不是每次都要手动粘一遍校验代码。
第三,善用model_dump(include/exclude=...)做数据脱敏和裁剪。给前端返回用户信息时,用一个UserOut模型配合exclude={"password"}就能保证密码不会泄露。与其在多个地方手写“去掉密码”的逻辑,不如在模型层统一处理。我还常用exclude_none=True,把值为 None 的字段从 JSON 中省略,前端就不用处理一堆无意义的 null 了。
第四,不要让模型试图表达过于复杂的业务规则。Pydantic 擅长的是“数据形状与基础规则”的校验,而不是复杂状态机或跨实体的业务规则。如果某个规则涉及多个模型、数据库状态、权限上下文,那更适合放在服务层里通过代码逻辑判断。我在一个项目中曾经试图把支付金额、折扣、满减全部塞进模型校验器里,结果模型层越写越复杂,测试也不好写。后来这些规则挪到服务层,模型只保留“金额必须大于 0”这类数据本身的约束,整个代码反而清晰很多。
最后再说一个小细节:写模型注释和 Field 的 description。不要觉得这是多余的事。一个描述清晰的模型,配合model_json_schema(),会自动生成一份不错的接口数据字典,前端和后端沟通成本会下降很多。我见过很多团队接口文档混乱、字段语义不清晰,其实源头就是模型定义时没有把描述写清楚。Pydantic 给你的这套“声明式”工具,不只提升了工作效率,也在无形中规范了团队的数据契约,这是我从基础用法走到工程化应用后感受最深的一点。