Pydantic 数据验证实战:基于类型提示的模型约束、校验器与判别联合完整指南
【免费下载链接】pydanticData validation using Python type hints项目地址: https://gitcode.com/GitHub_Trending/py/pydantic
Pydantic 是一款基于 Python 类型提示(type hints)实现运行时数据验证与序列化的库,可以理解为"带运行时验证的 dataclass"。本文围绕.agents/skills/pydantic/SKILL.md的核心脉络展开,系统讲解字段约束与元数据(Field)、StringConstraints、校验器(AfterValidator/field_validator)、类型强制转换、前向注解、递归类型别名以及模型子类与判别联合等高频实战主题,并结合当前仓库 pydantic/fields.py、pydantic/functional_validators.py、pydantic/types.py 等源码给出底层实现依据。读完本文,你将掌握用 Pydantic 安全建模外部不可信数据(如 HTTP API 请求体)的完整方法论,并能避开最常见的陷阱。
Pydantic 最有价值的应用场景是处理外部不可信数据——例如定义 HTTP API 的请求与响应模型。它通过类型提示理解"应该如何验证(和序列化)"。但请注意:一般不建议用 Pydantic 去定义那些在用户代码内部实例化的类。这样做会失去灵活性(例如无法使用 Pydantic 不支持的第三方类型,且较难在初始化后修改字段值)。这种情况下,普通的 Python 类(或标准库 dataclass)通常更合适,因为静态类型检查器已经能捕获类型不匹配,无需引入运行时验证的开销与限制。
基本用法:一个最小的 Pydantic 模型
定义模型只需继承BaseModel并声明带类型注解的字段:
from datetime import date from pydantic import BaseModel, Field class Person(BaseModel): name: str age: int = Field(description='The age of the person') birthdate: date | None = None p = Person(name='John', age=20, birthdate='1970-01-01')关键行为:Pydantic 会强制转换(coerce)兼容的输入——上面的 ISO 日期字符串'1970-01-01'会被解析成date对象,而不是原样保留字符串。这正是"运行时验证"的核心价值:声明即校验,输入即规范化。
约束与字段元数据:Field()的两种元数据
Field()函数用于提供字段元数据与约束。使用时必须区分两类元数据:
- 字段专属元数据(field specific):如
deprecated、alias,只有附着在字段上才有意义; - 类型专属元数据(type specific):包括
gt、max_length等约束,以及影响 JSON Schema 输出的元数据(如description、title)。
从源码看,pydantic/fields.py 中的_FromFieldInfoInputs完整定义了Field()支持的参数集合:alias、validation_alias、serialization_alias、title、description、examples、exclude、gt/ge/lt/le、multiple_of、strict、min_length/max_length、pattern、allow_inf_nan、max_digits/decimal_places、union_mode、discriminator、deprecated、json_schema_extra、frozen、validate_default、repr、init、kw_only、coerce_numbers_to_str、fail_fast等。这些元数据最终由FieldInfo类统一承载(pydantic/fields.py 中FieldInfo的annotation、default、default_factory、alias、metadata等属性),供后续生成 core schema 时使用。
两种声明方式
赋值形式(assignment form):
from pydantic import BaseModel, Field class User(BaseModel): first_name: str = Field(alias='name')Annotated 模式(annotated pattern):
from typing import Annotated from pydantic import BaseModel, Field class Model(BaseModel): value: Annotated[int, Field(deprecated=True)] = 1为什么优先推荐 Annotated 模式
- 使用
f: <type> = Field()(无默认值)的形式容易让人误以为f有默认值,而实际上该字段仍然是必填的; - 可以为一个字段提供任意数量的元数据元素。
Field()本身只支持有限的约束/元数据集,某些场景需要配合其他 Pydantic 工具(如WithJsonSchema)使用。
但需注意两点:
- 赋值形式应留给对静态类型检查器有意义的元数据:包括
alias、default和default_factory——这些必须让类型检查器"看得见"。 - 字段专属元数据只能放在"顶层"类型上。一个常见陷阱如下:
from typing import Annotated from pydantic import BaseModel, Field class Model(BaseModel): field_bad: Annotated[int, Field(deprecated=True)] | None = None field_ok: Annotated[int | None, Field(deprecated=True)] = None上面的field_bad中Field(deprecated=True)附着在int上,而不是整个int | None联合上,deprecated不会如预期生效;field_ok把Annotated包在整个int | None外层,字段专属元数据才能正确作用于整个联合类型。
约束(Constraints):优先内置约束,而不是自定义校验器
只要可能,应尽量使用 Pydantic/annotated_types的"内置"验证约束,而非手写自定义校验器:
from typing import Annotated from annotated_types import Gt # annotated_types 是 Field() 之外的另一选择 from pydantic import BaseModel, field_validator class Model(BaseModel): constrained_int_ok: Annotated[int, Gt(1)] # 推荐做法 constrained_int_bad: int @field_validator('constrained_int_bad') # 不推荐做法 @classmethod def validate(cls, v: int) -> int: if not v > 1: raise ValueError('Value is not greater than 1') return v内置约束的优势在于:声明式、可复用、能被 JSON Schema 生成器理解、性能更好(直接映射到 core schema),而自定义校验器需要为每个字段单独编写逻辑。
用StringConstraints处理字符串专用约束
有些约束无法用Field()表达。例如字符串约束strip_whitespace、to_upper、to_lower、ascii_only只能通过pydantic.StringConstraints指定:
from typing import Annotated from pydantic import BaseModel, StringConstraints class Model(BaseModel): # 用这个,而不是写一个调用 s.strip() 的校验器: a: Annotated[str, StringConstraints(strip_whitespace=True)]从 pydantic/types.py 的StringConstraints定义(继承自annotated_types.GroupedMetadata)可以看到它支持的完整参数:strip_whitespace(去除首尾空白)、to_upper(转大写)、to_lower(转小写)、strict(严格模式)、min_length/max_length(长度上下限)、pattern(正则模式)、ascii_only(仅允许 ASCII 字符)。其__iter__实现会把长度与严格约束转换为MinLen/MaxLen/Strict元数据,把字符串变换类约束打包为通用元数据,最终喂给 core schema。
历史上 Pydantic 提供过constr()函数,但源码中已明确标注"discouraged",并将在 Pydantic 3.0 中弃用——因为它返回的是类型,不利于静态分析工具,官方推荐一律改用Annotated[str, StringConstraints(...)]形式(见 pydantic/types.py 中的constr文档字符串)。
关于标准库类型及其可用的完整约束列表,仓库内的权威文档是 docs/api/standard_library_types.md,可据此查阅str、int、float、Decimal、Path、datetime等类型的全部约束能力。
校验器(Validators):尽量使用 after 校验器与 Annotated 模式
某些场景必须使用自定义校验器。此时应尽可能使用 after 校验器:因为它们运行在 Pydantic 自身验证之后,此时值已经是字段声明类型;若使用 before 校验器,输入数据可以是任何东西,更易出错——尤其是模型级校验器,输入不一定是个 dict,也可能是任意对象。
若条件允许,优先采用 Annotated 模式声明校验器:
from typing import Annotated from pydantic import AfterValidator, BaseModel, field_validator def is_even(value: int) -> int: if value % 2 == 1: raise ValueError(f'{value} is not an even number') return value class Model(BaseModel): # 推荐这种形式:校验器紧挨着字段,易于理解 even: Annotated[int, AfterValidator(is_even)] odd: int # 如果用装饰器定义校验器,务必声明为 classmethod。 @field_validator('odd', mode='after') @classmethod def is_odd(cls, value: int) -> int: if value % 2 == 0: raise ValueError(f'{value} is not an odd number') return value从实现上看,pydantic/functional_validators.py 中的AfterValidator是一个冻结 dataclass,其__get_pydantic_core_schema__会根据校验函数签名是否接收info参数,分别生成with_info_after_validator_function或no_info_after_validator_function两种 core schema——这也是为什么 after 校验器能够"干净地"拿到已转换为目标类型的值。而field_validator函数的mode参数支持'before'、'after'、'wrap'、'plain',默认就是'after',并可通过check_fields控制是否检查字段真实存在(见 pydantic/functional_validators.py 的field_validator定义)。
装饰器模式(@field_validator)会导致行为不够清晰,尤其在子类继承时校验器的执行顺序难以预测——这正是上文推荐 Annotated 模式的核心原因。
类型强制转换、集合与联合(Unions)
只要没有启用严格模式(见仓库文档 docs/concepts/strict_mode.md),Pydantic 在大多数情况下都会进行类型强制转换。例如字段类型为int时,字符串'123'会被接受;这一规则同样适用于集合类型:list[str]也会接受 tuple、set 等输入。
因此应当避免:
- 使用
int | str这类联合,如果你的目标是通过校验器把str强转成int——联合会让输入先尝试按字面类型匹配,行为难以预料; - 使用
collections.abc.Sequence这类抽象集合,如果你的目标是同时接受 list 和 tuple——抽象集合的校验是低效的。
一般原则:联合类型尽量少用,因为字段的每次使用都需要先判断类型再操作,无论是验证性能还是代码可读性都不划算。
前向注解(Forward Annotations)
Python 允许用字符串书写前向引用注解,但这会给 Pydantic 求值注解带来挑战,能避免就避免。
- 如果在一个模块中定义 Pydantic 模型,尽量避免使用
from __future__ import annotations(它会默认把所有注解字符串化); - 只对"尚未定义"的注解显式加引号,例如自引用:
from pydantic import BaseModel class Model(BaseModel): self_ref: 'Model'- 注意:Python >= 3.14 中注解求值默认被延迟,届时不应再使用字符串注解。关于前向引用求值机制的更多细节可参考仓库文档 docs/concepts/forward_annotations.md。
递归类型别名
你可能会想这样定义递归别名:
from typing import TypeAlias JsonValue: TypeAlias = 'list[JsonValue] | dict[str, JsonValue] | str | bool | int | float | None'因为别名是递归的,所以需要加引号;但 Pydantic通常无法求值这种带引号的TypeAlias。正确做法是使用显式类型别名——Python 3.12+ 用type语句,或使用TypeAliasType:
type JsonValue = list[JsonValue] | dict[str, JsonValue] | str | bool | int | float | None # 或者,如果 Python 版本 < 3.12: from typing_extensions import TypeAliasType JsonValue = TypeAliasType('JsonValue', 'list[JsonValue] | dict[str, JsonValue] | str | bool | int | float | None')TypeAliasType创建的显式别名对象是 Pydantic 可以解析的,这是处理 JSON 等递归数据结构的推荐姿势。
模型子类、判别联合(Discriminated Unions)与泛型
继承是 Python 中非常常见的模式,但在 Pydantic 里可能是个"坑"。请看下面的例子:
from pydantic import BaseModel class Base(BaseModel): base_field: int def common_method(self) -> None: ... class Sub1(Base): sub1_field: str class Sub2(Base): sub2_field: bool class Main(BaseModel): model: Base m: Main = Main(model=Sub1(base_field=1, sub1_field='test'))这个例子能跑通,但序列化m时结果不符合预期:
m.model_dump() #> {'model': {'base_field': 1}} -> sub1_field 丢失了原因在于:Pydantic 按声明类型(Base)进行序列化,而不是按运行时子类。验证也遵循同样规则:Main(model={'base_field': 1, 'sub1_field': 'test'})会按Base验证,sub1_field被忽略而不是生成Sub1实例。相关行为可以在 tests/test_main.py 的模型序列化/继承相关测试中看到印证(如test_model_export_exclusion_inheritance、test_model_export_inclusion_inheritance)。
方案一:判别联合(推荐,前提是能设置一个type字段区分模型)
from typing import Annotated, Literal, TypeAlias from pydantic import BaseModel, Field class Sub1(Base): type: Literal['sub1'] sub1_field: str class Sub2(Base): type: Literal['sub2'] sub2_field: bool Subs: TypeAlias = Annotated[Sub1 | Sub2, Field(discriminator='type')] class Main(BaseModel): model: SubsField(discriminator='type')会在底层把普通联合改写为带标签的联合(tagged union)。从 pydantic/_internal/_discriminated_union.py 的apply_discriminator实现看,Pydantic 会先验证判别字段在所有联合成员中一致存在且为Literal类型,再根据判别字段取值把输入映射到对应的具体模型,从而在验证与序列化两个方向都保留真实子类信息。仓库的判别联合测试位于 tests/types/unions/test_discriminated_union.py,覆盖了单变体非法、递归判别联合、别名判别字段等边界情况。
方案二:泛型模型(Generics)
from pydantic import BaseModel class MainBaseT: Base: model: BaseT m: Main[Sub1] = MainSub1 # 可以工作通过把具体子类作为类型参数传入,Pydantic 会针对Sub1生成对应的验证与序列化 schema。
最后手段:多态序列化与 SerializeAsAny
如果判别联合和泛型都不合适,还可以退而求其次:
- 多态序列化(polymorphic serialization):适用于 Pydantic >= 2.13,见仓库文档 docs/concepts/serialization.md;
- 序列化为任意类型(SerializeAsAny):适用于 Pydantic < 2.13。该能力由 pydantic/functional_serializers.py 中的
SerializeAsAny提供(SerializeAsAny[list[str]]对类型检查器而言等价于list[str]),它指示序列化器忽略声明的外层类型、按实际运行时的值类型序列化,已在 pydantic/init.py 中公开导出。
实战决策小结
把本文要点归纳为一份可复用的决策清单:
| 场景 | 推荐做法 | 理由 |
|---|---|---|
| 定义 API 请求/响应模型 | BaseModel+ 类型注解 | 运行时验证外部不可信数据 |
| 代码内部使用的类 | 普通类或标准库 dataclass | 静态检查足够,避免灵活性损失 |
| 简单约束(大小、长度、正则) | Field(gt=..., max_length=..., pattern=...)或annotated_types约束 | 声明式、可生成 JSON Schema、性能好 |
| 字符串变换约束 | StringConstraints(strip_whitespace=...) | Field()无法表达这类约束 |
| 自定义校验 | Annotated[..., AfterValidator(f)] | 值已是目标类型,顺序可控 |
| 自引用/递归结构 | 显式type别名或TypeAliasType | 带引号的TypeAlias无法被求值 |
| 需要保留子类信息 | 判别联合(Field(discriminator=...))或泛型 | 普通继承按声明类型验证/序列化 |
在动手编写自定义校验逻辑之前,请先确认仓库内 docs/api/standard_library_types.md 与 docs/concepts/validators.md 中是否已有现成的内置约束或校验方案——"用内置约束替代手写校验器"是 Pydantic 使用中最重要的性能与可维护性准则。
【免费下载链接】pydanticData validation using Python type hints项目地址: https://gitcode.com/GitHub_Trending/py/pydantic
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考