Python 基础体系 · 第 77/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Pydantic v2:模型、校验器、序列化、Settings 和性能边界
Pydantic 是一个以 Python 类型标注为输入、以运行时数据校验和结构化转换为核心的库。它常用于 HTTP 请求体、配置、消息队列事件、数据库边界和外部 API 响应的建模。
本文以 Python 3.14 的类型语法为背景,讨论 Pydantic v2 的五个核心部分:
- 模型如何从类型标注生成校验结构;
- 字段校验器和模型校验器如何参与数据流;
- Python 对象、字典和 JSON 之间如何序列化;
pydantic-settings如何加载环境变量、.env和 Secret;- Pydantic 的性能来自哪里,又在哪些边界上会失效。
示例使用 Pydantic v2 风格 API,例如 model_validate()、model_dump()、@field_validator 和 @model_validator。Pydantic v2 的模型会在类定义阶段分析类型标注并构建内部 schema,之后基于该 schema 执行校验和序列化。模型实例化成功后,Pydantic 保证结果对象符合声明的字段类型和约束;它保证的是处理后的输出,而不是原始输入本身。(docs.pydantic.dev)
一、先建立正确的模型:类型标注不是校验,模型才是运行时边界
Python 的类型标注主要服务于静态分析:
def add(a: int, b: int) -> int:
return a + b
在普通 Python 中,下面的调用不会因为标注而自动失败:
add("1", "2")
类型标注不会自动改变 Python 的运行时行为。
Pydantic 模型则把类型标注变成了运行时校验规则:
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str = "anonymous"
user = User(id="123")
print(user)
print(user.id, type(user.id))
print(user.model_dump())
预期输出类似:
id=123 name='anonymous'
123 <class 'int'>
{'id': 123, 'name': 'anonymous'}
这里发生了三步转换:
原始输入 {"id": "123"}
↓
根据字段声明执行校验和转换
↓
模型实例 User(id=123, name="anonymous")
id: int 不仅表示“希望它是整数”,还定义了 Pydantic 应如何处理输入。默认情况下,Pydantic 会进行一定程度的类型转换,例如把可解析的字符串 "123" 转为整数 123。这种行为是 Pydantic 的设计选择,但也意味着“类型校验”和“输入清洗”在默认模式下是连在一起的。(docs.pydantic.dev)
1. 必填、可空和默认值是三个不同概念
下面三个字段的语义不同:
from pydantic import BaseModel
class Example(BaseModel):
required_value: int
nullable_value: int | None
optional_value: int | None = None
它们分别表示:
| 字段 | 是否必须出现 | 是否允许 None |
没有输入时的行为 |
|---|---|---|---|
required_value |
是 | 否 | 校验失败 |
nullable_value |
是 | 是 | 校验失败 |
optional_value |
否 | 是 | 使用 None |
因此,int | None 只表示“值可以是整数或 None”,并不自动表示“字段可以缺省”。
from pydantic import ValidationError
try:
Example(required_value=1)
except ValidationError as exc:
print(exc.errors())
nullable_value 仍然缺失,所以模型创建失败。
如果业务语义是“字段可以不传”,必须提供默认值:
class PatchUser(BaseModel):
display_name: str | None = None
这一区别在 PATCH 请求中尤其重要:
- 字段缺失:通常表示“不修改”;
- 字段为
null:通常表示“明确清空”。
Pydantic 会记录哪些字段是调用者显式提供的:
patch = PatchUser()
print(patch.model_fields_set)
print(patch.model_dump(exclude_unset=True))
预期输出:
set()
{}
如果之后显式赋值:
patch.display_name = None
print(patch.model_fields_set)
print(patch.model_dump(exclude_unset=True))
则会得到:
{'display_name'}
{'display_name': None}
exclude_unset=True 依赖的正是这组“显式设置字段”信息。(pydantic.dev)
2. 使用 Field 描述字段约束、别名和文档信息
字段的类型只表达基本类型,长度、范围、别名等约束可以通过 Field 表达:
from typing import Annotated
from pydantic import BaseModel, Field
class CreateUser(BaseModel):
username: Annotated[str, Field(min_length=3, max_length=32)]
age: Annotated[int, Field(ge=0, le=150)]
email: str
Annotated 的含义是:字段的主类型仍然是 str 或 int,同时附带额外的 Pydantic 元数据。
也可以使用赋值形式:
class Product(BaseModel):
name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0)
两种形式在运行时都可以建立约束。Annotated 更适合复用类型约束:
PositivePrice = Annotated[float, Field(gt=0)]
ShortName = Annotated[str, Field(min_length=1, max_length=100)]
class Product(BaseModel):
name: ShortName
price: PositivePrice
需要区分三类约束:
- 类型约束:例如必须是
int; - 结构约束:例如必须是
list[int]; - 业务约束:例如结束时间必须晚于开始时间。
前两类通常可以直接由类型标注和 Field 表达,第三类通常需要校验器。
3. extra 决定未知字段的处理方式
假设输入包含模型没有声明的字段:
from pydantic import BaseModel
class User(BaseModel):
id: int
user = User(id=1, debug=True)
print(user.model_dump())
默认情况下,未知字段会被忽略,结果是:
{'id': 1}
可以通过 model_config 修改:
from pydantic import BaseModel, ConfigDict
class IgnoreExtra(BaseModel):
model_config = ConfigDict(extra="ignore")
id: int
class ForbidExtra(BaseModel):
model_config = ConfigDict(extra="forbid")
id: int
class AllowExtra(BaseModel):
model_config = ConfigDict(extra="allow")
id: int
三种模式的含义是:
ignore:忽略未知字段;forbid:遇到未知字段直接失败;allow:保留未知字段,存储在额外字段容器中。
对外部 API 输入,forbid 能更早发现客户端拼写错误;对需要向后兼容的事件协议,ignore 或 allow 可能更合适。Pydantic 的默认模型行为是忽略额外字段,extra 配置可以在模型级别控制这一点。(docs.pydantic.dev)
二、Pydantic 的数据流:Python 模式、JSON 模式和字符串模式
Pydantic v2 不是只有一种校验入口。常用入口可分为三种。
1. Python 模式
from pydantic import BaseModel
class Order(BaseModel):
order_id: int
amount: float
order = Order.model_validate({
"order_id": "1001",
"amount": "19.90",
})
print(order)
这里输入的是已经存在于 Python 内存中的字典。Pydantic 从字典中读取字段,再执行校验和转换。
构造器也属于 Python 模式:
order = Order(order_id="1001", amount="19.90")
model_validate() 的优点是可以明确表达“这是一次模型校验”,并且支持传入模型实例或其他对象。
2. JSON 模式
如果数据原本就是 JSON 字节或 JSON 字符串,可以直接使用:
payload = b'{"order_id": "1001", "amount": "19.90"}'
order = Order.model_validate_json(payload)
print(order)
与下面的写法相比:
import json
data = json.loads(payload)
order = Order.model_validate(data)
model_validate_json() 少了一次由 Python json 模块产生中间字典的过程。官方性能建议通常是:对于 JSON 输入,优先使用 model_validate_json();不过在包含 before 或 wrap 模型校验器的特殊场景下,两步方式可能更快,因此最终仍应以实际基准为准。(pydantic.dev)
数据流可以表示为:
JSON bytes
↓
Pydantic JSON 解析器
↓
core schema 校验
↓
Order 实例
而两步写法是:
JSON bytes
↓
json.loads()
↓
Python dict
↓
Pydantic Python 模式校验
↓
Order 实例
少创建一个中间结构并不意味着任何情况下都必然更快,因为自定义校验器可能改变成本结构。
3. 字符串模式
环境变量、命令行参数和表单数据经常是“键和值都是字符串”的字典:
data = {
"order_id": "1001",
"amount": "19.90",
}
order = Order.model_validate_strings(data)
print(order)
字符串模式会按 JSON 风格处理字符串,并尝试将其转换成字段声明的类型。它适合配置输入,但不应被误认为“所有字符串都可以安全转换”。
例如:
class Flags(BaseModel):
enabled: bool
count: int
print(Flags.model_validate_strings({
"enabled": "true",
"count": "3",
}))
输出:
enabled=True count=3
如果输入来自非 JSON 数据源,但希望获得接近 JSON 模式的解析行为,可以使用 model_validate_strings(),或者先把输入转换成 JSON 再校验。不同模式在严格性和特定类型上的行为可能不同。(docs.pydantic.dev)
三、校验器:把“字段能否转换”与“业务是否成立”分开
Pydantic 内置校验可以处理许多类型和约束,但业务规则通常需要自定义校验器。
校验器的基本契约是:
输入值
↓
校验器
├── 返回新值:继续后续校验
└── 抛出异常:产生 ValidationError
校验器如果返回值,就必须返回最终要进入模型的值。只检查不返回,或者忘记 return,会把字段值变成 None,从而产生额外错误。(pydantic.dev)
1. after 字段校验器:优先处理已完成类型转换的值
from pydantic import BaseModel, field_validator
class EvenNumber(BaseModel):
value: int
@field_validator("value", mode="after")
@classmethod
def must_be_even(cls, value: int) -> int:
if value % 2 != 0:
raise ValueError("value 必须是偶数")
return value
执行顺序是:
输入 "12"
↓
Pydantic 内置 int 校验
↓
得到 12
↓
must_be_even(12)
↓
模型创建成功
输入 "13" 时,内置校验先得到整数 13,随后业务校验失败。
after 校验器的优势是类型更稳定:
def must_be_even(value: int) -> int:
...
校验器可以直接使用整数运算,而不必处理字符串、浮点数、列表等任意输入。
2. before 字段校验器:处理原始输入
before 在 Pydantic 内部类型校验之前执行,因此接收到的值可能是任意对象:
from typing import Any
from pydantic import BaseModel, field_validator
class Numbers(BaseModel):
values: list[int]
@field_validator("values", mode="before")
@classmethod
def ensure_list(cls, value: Any) -> Any:
if isinstance(value, list):
return value
return [value]
现在以下输入都可以被处理:
print(Numbers(values=3))
print(Numbers(values=["1", "2"]))
输出:
values=[3]
values=[1, 2]
完整数据流是:
输入 3
↓
before 校验器:变成 [3]
↓
list[int] 校验器:校验列表元素
↓
Numbers(values=[3])
但 before 校验器必须防御性地处理输入:
Numbers(values=("1", "2"))
这里元组是否应该被视为一个整体、转换成列表,还是被包装成 [("1", "2")],都取决于校验器的实现。原始值并不一定是你预想的类型。
另一个风险是:在联合类型校验中,如果 before 校验器修改了传入对象后又抛错,被修改后的对象可能继续传递给其他分支。因此,失败前不要随意原地修改输入,尤其是列表和字典。(pydantic.dev)
3. plain 和 wrap:控制内部校验流程
plain 校验器可以直接决定结果,可能绕过原本的类型校验:
from typing import Any
from pydantic import BaseModel, field_validator
class AnyInteger(BaseModel):
value: int
@field_validator("value", mode="plain")
@classmethod
def accept_anything_as_one(cls, value: Any) -> int:
return 1
print(AnyInteger(value="not-an-integer"))
输出:
value=1
这说明 plain 不是“在类型校验之后增加一条规则”,而是可以接管整个字段校验流程。除非你明确需要这种行为,否则 after 通常更容易推理。
wrap 校验器则可以包裹内部校验逻辑:
from typing import Any
from pydantic import BaseModel, ValidatorFunctionWrapHandler, field_validator
class Name(BaseModel):
name: str
@field_validator("name", mode="wrap")
@classmethod
def strip_and_validate(
cls,
value: Any,
handler: ValidatorFunctionWrapHandler,
) -> str:
if isinstance(value, str):
value = value.strip()
return handler(value)
调用 handler(value) 才会继续执行 Pydantic 的内部校验。wrap 适合:
- 需要在校验前后包裹逻辑;
- 需要捕获内部校验错误并转换成另一种结果;
- 需要根据上下文决定是否继续内部校验。
但它更复杂,也更难让内部校验器保持高速。官方性能文档明确建议:如果性能非常关键,应谨慎使用 wrap 校验器。(pydantic.dev)
4. Annotated 校验器与装饰器校验器
同一个“偶数”约束可以写成可复用类型:
from typing import Annotated
from pydantic import AfterValidator, BaseModel
def is_even(value: int) -> int:
if value % 2 != 0:
raise ValueError("必须是偶数")
return value
EvenInt = Annotated[int, AfterValidator(is_even)]
class Scores(BaseModel):
first: EvenInt
values: list[EvenInt]
这里 values: list[EvenInt] 表示对列表中的每个元素应用 EvenInt 约束,而不是只校验列表本身。
选择原则可以精确描述为:
- 约束属于某种类型本身:使用
Annotated; - 同一个模型中多个字段共用字段名相关逻辑:使用
@field_validator; - 约束依赖多个字段之间的关系:使用
@model_validator。
5. 模型校验器:处理跨字段不变量
单字段校验器无法表达“两个字段之间”的关系。例如:
from typing_extensions import Self
from pydantic import BaseModel, model_validator
class PasswordChange(BaseModel):
password: str
password_repeat: str
@model_validator(mode="after")
def passwords_match(self) -> Self:
if self.password != self.password_repeat:
raise ValueError("两次密码不一致")
return self
mode="after" 的流程是:
password ─┐
├─ 字段类型校验
password_repeat─┘
↓
构造模型实例
↓
passwords_match(self)
↓
返回 self
after 模型校验器接收的是已经完成字段校验的模型实例,因此可以安全访问字段,并且必须返回该实例。(pydantic.dev)
before 模型校验器则接收原始模型输入:
from typing import Any
from pydantic import BaseModel, model_validator
class Payment(BaseModel):
card_token: str
@model_validator(mode="before")
@classmethod
def reject_raw_card_number(cls, data: Any) -> Any:
if isinstance(data, dict) and "card_number" in data:
raise ValueError("不允许提交原始银行卡号")
return data
两者的差异不是装饰器名字不同,而是输入状态不同:
| 模式 | 接收值 | 适合场景 |
|---|---|---|
before |
原始字典或任意对象 | 预处理、拒绝敏感输入、兼容旧格式 |
after |
已校验的模型实例 | 跨字段不变量、派生状态确认 |
wrap |
原始值与内部处理句柄 | 需要包裹或干预整个流程 |
字段校验按字段定义顺序进行。字段校验器通过 ValidationInfo.data 访问其他已校验字段时,不能假设后面定义的字段已经存在。因此,依赖关系复杂时,应优先使用模型级 after 校验器,或者调整字段定义顺序。(pydantic.dev)
四、错误模型:ValidationError 是结构化错误,不只是错误字符串
不要只记录:
try:
User(id="wrong")
except Exception as exc:
print(str(exc))
更有用的是读取结构化错误:
from pydantic import BaseModel, ValidationError
class User(BaseModel):
id: int
email: str
try:
User(id="wrong", email=123)
except ValidationError as exc:
for error in exc.errors():
print(error)
错误项通常包含:
{
"type": "int_parsing",
"loc": ("id",),
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "wrong",
}
其中:
type:机器可读的错误类型;loc:错误路径,可以包含字段名、列表下标和嵌套位置;msg:面向人的说明;input:触发错误的原始输入;ctx:某些约束的参数,例如最小值、最大值。
对于嵌套数据:
class Item(BaseModel):
sku: str
quantity: int
class Order(BaseModel):
items: list[Item]
try:
Order(items=[
{"sku": "A", "quantity": 2},
{"sku": "B", "quantity": "bad"},
])
except ValidationError as exc:
print(exc.errors()[0])
错误位置会包含:
('items', 1, 'quantity')
这类路径可以直接映射到 Web API 的字段错误、表单错误或消息队列的失败记录。
Pydantic 的错误表示和 HTTP 状态码不是同一个层次:
Pydantic ValidationError
↓
应用层识别输入错误
↓
FastAPI 返回 422 或自定义错误响应
Pydantic 负责说明“数据不符合模型”,FastAPI 负责把该异常转换为 HTTP 响应。FastAPI 将 Pydantic 模型用于请求体时,会读取 JSON、执行类型转换和校验,并把模型 schema 纳入 OpenAPI 文档。(fastapi.tiangolo.com)
五、序列化:model_dump 不是简单的 __dict__
校验方向是:
外部数据 → Pydantic 模型
序列化方向是:
Pydantic 模型 → Python 基础结构或 JSON
Pydantic 文档通常把 dump 和 serialize 视为相近概念:既可能表示转换为字典,也可能表示转换为 JSON 字符串。(pydantic.dev)
1. model_dump():生成 Python 对象
from datetime import datetime
from pydantic import BaseModel
class Event(BaseModel):
name: str
happened_at: datetime
tags: tuple[str, ...]
event = Event(
name="deploy",
happened_at="2026-09-01T10:00:00",
tags=("web", "api"),
)
print(event.model_dump())
Python 模式可能保留 Python 专有类型:
{
"name": "deploy",
"happened_at": datetime(...),
"tags": ("web", "api"),
}
如果需要得到 JSON 兼容的 Python 结构:
print(event.model_dump(mode="json"))
输出中的时间会变成字符串,元组会变成列表:
{
"name": "deploy",
"happened_at": "2026-09-01T10:00:00",
"tags": ["web", "api"],
}
因此:
model_dump()适合 Python 内部处理;model_dump(mode="json")适合交给 JSON 编码器;model_dump_json()直接产生 JSON 文本。
2. model_dump_json():直接产生 JSON 字符串
json_text = event.model_dump_json(indent=2)
print(type(json_text))
print(json_text)
BaseModel.model_dump_json() 返回的是 str。Pydantic 会处理日期时间、UUID、集合等常见类型;如果遇到无法转换为 JSON 的对象,则会在序列化阶段抛出 PydanticSerializationError。这种错误可能直到真正构造响应时才暴露。(pydantic.dev)
3. 字段别名:输入名与输出名可以不同
from pydantic import BaseModel, Field
class User(BaseModel):
user_id: int = Field(
validation_alias="userId",
serialization_alias="user_id",
)
user = User.model_validate({"userId": 10})
print(user.user_id)
print(user.model_dump())
print(user.model_dump(by_alias=True))
结果分别是:
10
{'user_id': 10}
{'user_id': 10}
如果序列化别名改成驼峰形式:
class User(BaseModel):
user_id: int = Field(
validation_alias="userId",
serialization_alias="userId",
)
则:
print(user.model_dump(by_alias=True))
输出:
{"userId": 10}
这里要区分:
validation_alias:输入数据如何找到字段;serialization_alias:输出数据如何命名;alias:同时影响输入和输出的统一别名。
这对数据库字段、内部 Python 命名和公开 API 命名不一致的系统很重要。
4. exclude_none、exclude_defaults 和 exclude_unset
from pydantic import BaseModel
class Profile(BaseModel):
name: str
nickname: str | None = None
level: int = 1
profile = Profile(name="alice")
print(profile.model_dump())
print(profile.model_dump(exclude_none=True))
print(profile.model_dump(exclude_defaults=True))
print(profile.model_dump(exclude_unset=True))
这些选项的判断依据不同:
exclude_none=True:排除值为None的字段;exclude_defaults=True:排除值等于默认值的字段;exclude_unset=True:排除创建模型时没有显式提供的字段。
例如,level 当前值是默认值 1,但如果调用者显式传入:
profile = Profile(name="alice", level=1)
则:
profile.model_dump(exclude_unset=True)
会保留 level,因为它是显式设置过的。
不要用 exclude_none=True 代替 PATCH 语义。因为“显式设置为 None”可能本身就是业务动作,而不是应该被删除的信息。
5. 子类序列化与敏感字段泄露
Pydantic v2 默认更倾向于按照声明类型的字段集合序列化嵌套模型,而不是无条件把运行时子类的所有字段都输出。
from pydantic import BaseModel
class PublicUser(BaseModel):
name: str
class InternalUser(PublicUser):
password_hash: str
class Response(BaseModel):
user: PublicUser
response = Response(
user=InternalUser(name="alice", password_hash="secret")
)
print(response.model_dump())
通常只会按 PublicUser 的字段集合输出:
{"user": {"name": "alice"}}
如果明确需要按运行时实际类型序列化,可以使用 SerializeAsAny 或运行时的 serialize_as_any 设置。但这会扩大输出字段集合,因此不能为了“方便”随意打开,尤其是在响应模型包含密钥、令牌或内部状态时。Pydantic v2 提供了 serialize_as_any 相关控制项来显式改变该行为。(pydantic.dev)
六、TypeAdapter:没有 BaseModel 时仍然使用同一套 schema 能力
不是所有数据都适合包装成 BaseModel。
例如,消息队列收到的是:
[
{"id": "1", "name": "A"},
{"id": "2", "name": "B"},
]
你可能只想校验 list[User],不需要再创建一个名为 UserList 的根模型。
from pydantic import BaseModel, TypeAdapter
class User(BaseModel):
id: int
name: str
user_list_adapter = TypeAdapter(list[User])
users = user_list_adapter.validate_python([
{"id": "1", "name": "A"},
{"id": "2", "name": "B"},
])
print(users)
print(user_list_adapter.dump_json(users))
输出类似:
[User(id=1, name='A'), User(id=2, name='B')]
b'[{"id":1,"name":"A"},{"id":2,"name":"B"}]'
TypeAdapter 可以对没有继承 BaseModel 的类型执行校验、序列化和 JSON Schema 生成,例如:
list[int]dict[str, float]TypedDictdataclassAnnotated类型- 联合类型
官方文档特别指出,TypeAdapter.dump_json() 返回 bytes,而 BaseModel.model_dump_json() 返回 str,这是两套 API 的历史兼容差异。(pydantic.dev)
1. 不要在热路径中重复创建 TypeAdapter
错误写法:
def parse_users(data: object) -> list[User]:
adapter = TypeAdapter(list[User])
return adapter.validate_python(data)
每次调用都可能重新构建相关的校验器和序列化器。
更适合重复调用的写法:
USER_LIST_ADAPTER = TypeAdapter(list[User])
def parse_users(data: object) -> list[User]:
return USER_LIST_ADAPTER.validate_python(data)
TypeAdapter 的构建成本应该摊薄到应用初始化阶段,而不是放在每条消息、每个请求或每个循环迭代中。官方性能建议也是复用已经创建的 TypeAdapter。(pydantic.dev)
七、Settings:配置也是外部输入,必须经过同样的结构化校验
Pydantic v2 把 Settings 能力放在独立的 pydantic-settings 包中:
pip install pydantic-settings
BaseSettings 继承自 Pydantic 的模型体系,但字段值不仅来自构造器,还可以来自环境变量、.env 文件和 Secret 文件。(pydantic.dev)
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "demo"
debug: bool = False
port: int = 8000
database_url: str
api_key: SecretStr
model_config = SettingsConfigDict(
env_prefix="APP_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
假设环境中存在:
export APP_DEBUG=true
export APP_PORT=9000
export APP_DATABASE_URL=postgresql://localhost/demo
export APP_API_KEY=secret-value
执行:
settings = Settings()
print(settings.app_name)
print(settings.debug)
print(settings.port)
print(settings.database_url)
print(settings.api_key.get_secret_value())
输出:
demo
True
9000
postgresql://localhost/demo
secret-value
配置读取并不是简单的 os.getenv():
多个来源
↓
按优先级合并
↓
Pydantic 字段校验
↓
Settings 实例
1. 配置来源的优先级
默认优先级从高到低通常是:
- CLI 参数;
Settings(...)构造器参数;- 环境变量;
.env文件;- Secret 目录;
- 字段默认值。
因此:
settings = Settings(port=7000)
会覆盖:
APP_PORT=9000
最终端口是 7000。
Settings 文档明确列出了这些来源的优先级,并说明高优先级来源会覆盖低优先级来源;嵌套结构还可能发生深度合并。(pydantic.dev)
2. 嵌套配置与 JSON 环境变量
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class DatabaseConfig(BaseModel):
host: str
port: int = 5432
class Settings(BaseSettings):
database: DatabaseConfig
model_config = SettingsConfigDict(env_prefix="APP_")
可以通过顶层 JSON 环境变量:
export APP_DATABASE='{"host":"db.internal","port":5433}'
也可以启用嵌套分隔符:
class Settings(BaseSettings):
database: DatabaseConfig
model_config = SettingsConfigDict(
env_prefix="APP_",
env_nested_delimiter="__",
)
然后:
export APP_DATABASE__HOST=db.internal
export APP_DATABASE__PORT=5433
嵌套环境变量通常会覆盖同一字段的顶层 JSON 配置。例如同时存在 APP_DATABASE 和 APP_DATABASE__PORT 时,后者优先作用于 port。(pydantic.dev)
3. 环境变量中的列表、字典和 JSON 解码
环境变量本质上是字符串,因此:
export APP_ALLOWED_ORIGINS='["https://a.example","https://b.example"]'
可以对应:
class Settings(BaseSettings):
allowed_origins: list[str]
model_config = SettingsConfigDict(env_prefix="APP_")
对于复杂类型,pydantic-settings 默认会尝试 JSON 解码。如果环境变量的格式来自旧系统,例如:
export APP_NUMBERS=1,2,3
则可以关闭默认解码,再通过字段校验器自行解析:
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
numbers: list[int]
model_config = SettingsConfigDict(
env_prefix="APP_",
enable_decoding=False,
)
@field_validator("numbers", mode="before")
@classmethod
def parse_numbers(cls, value: str | list[int]) -> list[int]:
if isinstance(value, str):
return [int(item.strip()) for item in value.split(",")]
return value
这个例子体现了配置处理的正确边界:
环境变量字符串
↓
Settings source
↓
before 校验器:处理特定格式
↓
list[int] 内置校验
↓
Settings.numbers
不要让一个通用 before 校验器猜测所有字段的格式。配置格式应尽量明确,否则同一个字符串可能在不同部署环境中产生不同含义。
4. .env 的额外字段行为
.env 文件与直接读取环境变量存在一个容易忽略的差异:Settings 可能把 .env 文件中的所有变量传给模型,因此 extra="forbid" 时,一个未声明的 .env 变量也可能导致校验失败。
APP_PORT=9000
APP_DEBUG=true
UNRELATED_VALUE=hello
如果 Settings 模型没有 unrelated_value,严格的 extra="forbid" 可能使构造失败。兼容旧项目或共享 .env 文件时,可以选择:
model_config = SettingsConfigDict(
env_file=".env",
extra="ignore",
)
Settings 文档特别说明了 dotenv 文件中额外变量的处理方式,以及 dotenv_filtering 等过滤选项。(pydantic.dev)
5. Settings 的缓存与生命周期
配置一般在进程启动时加载一次:
from functools import lru_cache
@lru_cache
def get_settings() -> Settings:
return Settings()
这样做的直接效果是:
第一次调用 get_settings()
↓
读取环境变量/.env/Secret
↓
校验并构造 Settings
后续调用
↓
复用同一个 Settings 实例
如果代码需要动态重载配置,就不能盲目使用永久缓存,而应明确:
- 谁触发重载;
- 新配置校验失败时是否保留旧配置;
- 并发请求读取新旧配置时的可见性;
- 已创建的数据库连接池是否需要重建。
Pydantic 负责构造和校验配置,但不会替你解决“配置变更与运行中资源”的生命周期问题。
八、严格模式:什么时候必须拒绝类型转换
默认转换适合 HTTP、环境变量和表单数据,但某些领域不允许隐式转换。
from pydantic import BaseModel, ConfigDict
class StrictRecord(BaseModel):
model_config = ConfigDict(strict=True)
count: int
enabled: bool
以下输入会失败:
StrictRecord(count="1", enabled="true")
因为严格模式要求输入更接近声明的实际类型,不再把字符串自动转换为整数或布尔值。
也可以只对单次调用开启严格模式:
class Record(BaseModel):
count: int
Record.model_validate({"count": "1"}, strict=True)
或者只对字段开启:
from typing import Annotated
from pydantic import BaseModel, Field
class Record(BaseModel):
count: Annotated[int, Field(strict=True)]
严格模式的选择应由数据源和业务语义决定:
| 数据来源 | 常见选择 |
|---|---|
| HTTP JSON | 默认转换或局部严格 |
| 环境变量 | 默认转换 |
| 金融金额、精度敏感字段 | 明确类型,必要时严格 |
| 内部已校验对象 | 可减少重复校验 |
| 安全边界、协议兼容性要求高 | 严格模式更容易发现错误 |
严格模式不是“更安全”的同义词。它只是减少隐式转换;如果字段本身使用 float 表示金额,严格模式也不能解决二进制浮点精度问题。
例如:
from decimal import Decimal
from pydantic import BaseModel
class Money(BaseModel):
amount: Decimal
金额边界应明确使用 Decimal、最小货币单位整数或其他精确表示,而不是只依赖 float 的类型校验。
九、JSON Schema:模型约束可以成为协议文档,但不是完整业务证明
Pydantic 可以从模型生成 JSON Schema:
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(min_length=1)
price: float = Field(gt=0)
schema = Product.model_json_schema()
print(schema)
Schema 可以表达:
- 字段名称;
- 字段类型;
- 是否必填;
- 最小值和最大值;
- 字符串长度;
- 数组元素类型;
- 嵌套结构;
- 枚举和联合类型。
FastAPI 会把 Pydantic 模型产生的 JSON Schema 纳入 OpenAPI,并在交互式文档中展示。(fastapi.tiangolo.com)
但 Schema 不等于任意业务逻辑的完整证明。例如:
class Booking(BaseModel):
start: datetime
end: datetime
“end 必须晚于 start”是跨字段关系,通常由 model_validator 实现,而不是只靠基础 JSON Schema 约束。
from datetime import datetime
from typing_extensions import Self
from pydantic import BaseModel, model_validator
class Booking(BaseModel):
start: datetime
end: datetime
@model_validator(mode="after")
def valid_interval(self) -> Self:
if self.end <= self.start:
raise ValueError("end 必须晚于 start")
return self
因此,协议层应分为两层:
JSON Schema
├── 描述结构和可生成的基础约束
↓
Pydantic 运行时校验
├── 执行类型转换
├── 执行字段约束
└── 执行跨字段业务规则
客户端可以利用 Schema 生成表单或 SDK,但服务器仍必须执行自己的运行时校验。
十、性能边界:Pydantic 快在哪里,慢在哪里
Pydantic v2 的性能不能简单归结为“用了 Rust,所以一定很快”。更准确的分析是:
模型类定义
↓
构建 core schema
↓
生成校验器和序列化器
↓
重复处理大量数据
如果 schema 构建一次、校验规则主要是内置类型和约束,那么重复调用的成本通常较低。真正的性能边界来自以下因素。
1. 不要在热路径中重复构建 schema
错误示例:
def parse_payload(data: object) -> list[int]:
return TypeAdapter(list[int]).validate_python(data)
正确方向是:
INT_LIST_ADAPTER = TypeAdapter(list[int])
def parse_payload(data: object) -> list[int]:
return INT_LIST_ADAPTER.validate_python(data)
同样的原则适用于动态创建模型、频繁重建序列化器和重复生成 Schema。
2. 具体容器通常比抽象容器更容易优化
如果业务明确接收列表:
items: list[int]
就不要无理由写成:
from collections.abc import Sequence
items: Sequence[int]
Sequence 需要检查并尝试适配更多序列类型;list 的输入空间更明确。Mapping 与 dict 也有类似差异。官方性能文档建议,在确定输入容器类型时,使用具体的 list、tuple、dict,而不是更宽泛的抽象类型。(pydantic.dev)
这不是说抽象类型错误,而是表达能力与校验成本之间的取舍。
3. 只在需要时校验
如果某个字段只是透传数据,不需要检查其结构:
from typing import Any
from pydantic import BaseModel
class Envelope(BaseModel):
trace_id: str
raw_payload: Any
Any 的语义是:不要在这里对值执行额外的结构校验,尽量原样保留。官方性能建议也把 Any 作为避免不必要校验的手段。(pydantic.dev)
但这会移动风险:
使用 Any
↓
当前边界成本下降
↓
数据错误可能延迟到更深层代码
因此,Any 适合明确的透传边界,不适合作为“模型写不出来”的替代品。
4. TypedDict 与嵌套 BaseModel 的取舍
如果只需要验证结构,不需要模型方法、属性赋值、模型级行为或对象身份,可以考虑:
from typing import TypedDict
from pydantic import TypeAdapter
class UserDict(TypedDict):
id: int
name: str
adapter = TypeAdapter(list[UserDict])
users = adapter.validate_python([
{"id": "1", "name": "alice"},
])
TypedDict 结果是普通字典,嵌套 BaseModel 结果是模型实例。
因此,选择依据不是“哪个永远更快”,而是:
- 是否需要模型行为;
- 是否需要领域方法;
- 是否需要在多个层次调用
model_dump(); - 是否只需要一次性结构校验;
- 是否要减少大量嵌套对象创建。
官方性能文档把 TypedDict 列为可考虑的性能方向,但这不应脱离数据访问方式和可维护性单独决定。(pydantic.dev)
5. Union 应优先使用可判别的 Tagged Union
普通联合类型:
class Cat(BaseModel):
name: str
lives: int
class Dog(BaseModel):
name: str
breed: str
class Pet(BaseModel):
pet: Cat | Dog
当输入同时满足多个分支或不满足任何分支时,校验器可能需要尝试多个分支。
如果数据协议中存在明确的类型字段,可以使用判别字段:
from typing import Annotated, Literal
from pydantic import BaseModel, Field
class Cat(BaseModel):
kind: Literal["cat"]
name: str
lives: int
class Dog(BaseModel):
kind: Literal["dog"]
name: str
breed: str
class Pet(BaseModel):
pet: Annotated[Cat | Dog, Field(discriminator="kind")]
输入:
pet = Pet.model_validate({
"pet": {
"kind": "dog",
"name": "Rex",
"breed": "collie",
}
})
校验器可以先读取 kind,直接选择 Dog 分支,而不必盲目尝试所有联合成员。官方性能建议也优先使用 tagged union,而不是没有判别字段的普通 union。(pydantic.dev)
6. 失败快速返回:FailFast 的语义取舍
默认校验通常会收集多个错误,方便一次性修复输入:
items.0.quantity 错误
items.2.quantity 错误
items.5.sku 错误
对于超大列表,如果只需要知道“输入是否可接受”,继续收集全部错误可能浪费时间。Pydantic 当前文档把 FailFast 列为可以考虑的性能能力。
这涉及一个明确取舍:
收集全部错误
优点:用户体验更好,诊断信息更多
缺点:失败输入越大,额外处理越多
失败即停
优点:坏数据尽快退出
缺点:一次只能看到一个或少量错误
批处理导入、用户表单和 API 请求通常更重视完整错误;攻击面较大的超大数组输入可能更重视失败快速返回。
7. Pydantic 不是数据库、网络和 JSON 的全部成本
一个请求的总耗时可以粗略写成:
其中:
- :网络传输和连接开销;
- :JSON 解析和编码;
- :Pydantic schema 校验;
- :业务逻辑;
- :数据库或外部服务等待;
- :响应对象转换和编码。
只有当 在总耗时中占据显著比例时,优化 Pydantic 才有意义。官方文档也明确提醒:大多数情况下,Pydantic 不会是应用的主要瓶颈。(pydantic.dev)
不要因为一次模型校验消耗了少量 CPU,就跳过输入校验。跳过校验会把成本转移到更晚、更难诊断的阶段,甚至变成错误数据写入数据库或错误响应泄露内部字段。
十一、绕过校验:model_construct() 不是普通构造器的优化版
Pydantic 提供:
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
user = User.model_construct(id="not-an-int", name=123)
print(user)
model_construct() 不执行正常校验,因此可能创建出不符合字段声明的对象。
它只适合非常明确的场景:
- 数据已经由可信的同一个 schema 校验过;
- 从数据库读取的内容已经经过严格保证;
- 需要避免重复校验,并且已经基准测试确认成本值得优化。
它不适合:
- HTTP 请求入口;
- 消息队列公网输入;
- 环境变量;
- 数据库迁移后的不确定旧数据;
- 任何未证明可信的对象。
模型方法列表中,model_construct() 被明确区分为“不运行校验的模型创建方式”。(docs.pydantic.dev)
十二、在 FastAPI 中形成完整链路
一个端到端的 FastAPI 示例:
from decimal import Decimal
from typing_extensions import Self
from fastapi import FastAPI
from pydantic import BaseModel, Field, model_validator
class CreateOrder(BaseModel):
product_id: int = Field(gt=0)
quantity: int = Field(gt=0, le=1000)
unit_price: Decimal = Field(gt=0)
@model_validator(mode="after")
def total_within_limit(self) -> Self:
total = self.quantity * self.unit_price
if total > Decimal("100000"):
raise ValueError("订单总金额不能超过 100000")
return self
class OrderResponse(BaseModel):
order_id: int
total: Decimal
app = FastAPI()
@app.post("/orders", response_model=OrderResponse)
async def create_order(order: CreateOrder) -> OrderResponse:
total = order.quantity * order.unit_price
# 实际应用中,这里通常还会访问数据库或服务。
return OrderResponse(
order_id=1001,
total=total,
)
请求:
{
"product_id": 10,
"quantity": 2,
"unit_price": "19.90"
}
数据流是:
HTTP 请求体
↓
FastAPI 读取 JSON
↓
CreateOrder 字段校验
↓
Decimal 转换
↓
模型级总金额校验
↓
进入 create_order()
↓
OrderResponse 响应校验和序列化
如果请求不合法,例如:
{
"product_id": 10,
"quantity": 0,
"unit_price": "19.90"
}
则不会进入业务函数,而是在请求体校验阶段失败。
这里有两个不同的校验边界:
CreateOrder:客户端输入是否满足接口协议;OrderResponse:服务端返回是否满足公开响应协议。
输入模型和输出模型不要为了复用代码而强行合并。一个字段可能只允许输入、不应出现在输出中;另一个字段可能由服务端生成、不应由客户端提交。
十三、常见误解与诊断路径
误解一:类型标注会自动阻止错误调用
错误理解:
def f(value: int) -> None:
...
就认为运行时一定只能传整数。
事实是:普通 Python 类型标注主要服务静态工具;运行时校验需要 Pydantic 模型、TypeAdapter、validate_call 或其他显式机制。
误解二:Optional 表示字段可以缺失
str | None 表示允许 None,不等于字段可以省略。需要缺省时提供 = None 或其他默认值。
误解三:model_dump() 一定得到 JSON
model_dump() 默认是 Python 模式,可能包含 datetime、tuple、set 等非 JSON 原生结构。需要 JSON 兼容结构时使用:
model.model_dump(mode="json")
需要 JSON 文本时使用:
model.model_dump_json()
误解四:exclude_none 就是 PATCH
exclude_none 只按值是否为 None 过滤;PATCH 是否区分“缺失”和“明确清空”,应使用 exclude_unset 以及字段显式设置状态。
误解五:Settings 只是 os.getenv() 的包装
Settings 还涉及:
- 类型转换;
- 嵌套模型;
- 多个来源;
- 来源优先级;
.env额外字段;- Secret 文件;
- 自定义 source;
- 配置实例生命周期。
当配置解析失败时,错误位置通常指向具体配置字段。Settings 文档也明确说明,来自环境变量或 Secret 文件的值仍然会经过 Pydantic 校验。(pydantic.dev)
误解六:Pydantic v2 只要加上 strict=True 就不会有业务错误
严格模式只能限制类型转换,不能保证:
- 用户是否存在;
- 商品库存是否足够;
- 时间区间是否合法;
- 金额是否超过账户额度;
- URL 指向的服务是否可达。
这些属于业务逻辑、外部状态或系统一致性问题,不是类型系统可以单独解决的。
误解七:只要 Pydantic 很快,就可以把大型文件一次性读进内存
Pydantic 的校验吞吐不能消除输入体积、内存占用和网络传输成本。对于大文件、批量导入和无限数组,应在协议层限制大小,并考虑:
流式读取
↓
分块解析
↓
批量校验
↓
批量写入
而不是把整个输入构造成一个巨大嵌套模型。
十四、一个可执行的选择框架
面对一段外部数据,可以按以下顺序判断:
第一步:数据是否可信
- 不可信:必须经过模型或
TypeAdapter; - 已由同一 schema 校验且生命周期受控:才考虑减少重复校验;
- 不确定:按不可信处理。
第二步:是否需要模型行为
- 需要领域方法、跨字段校验、清晰对象边界:
BaseModel; - 只需要校验
list[int]或dict[str, X]:TypeAdapter; - 只需要字典结构且不需要模型对象:
TypedDict + TypeAdapter。
第三步:输入是什么形态
- Python 字典:
model_validate(); - JSON 字符串或字节:
model_validate_json(); - 键值全部是字符串:
model_validate_strings(); - 环境变量和 Secret:
BaseSettings。
第四步:校验规则处于哪一层
- 字段类型:类型标注;
- 数值、长度、正则等:
Field; - 单字段预处理:
before; - 单字段类型确定后的约束:
after; - 多字段关系:
model_validator(mode="after"); - 需要包裹内部流程:
wrap,并接受额外复杂度和性能成本。
第五步:输出给谁
- Python 内部代码:
model_dump(); - JSON 编码器:
model_dump(mode="json"); - HTTP、消息队列或文件:
model_dump_json(); - 部分更新:根据业务语义选择
exclude_unset、exclude_none或显式字段集合; - 对外响应:使用独立响应模型,防止内部子类字段意外输出。
Pydantic v2 的核心价值不只是“把字典转成对象”,而是把外部数据在进入系统时变成一个有明确结构、有错误路径、有可序列化边界、可生成协议文档并可测量性能的运行时契约。真正可靠的用法,是先设计数据流和信任边界,再选择模型、校验器、Settings source 和序列化方式。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:FastAPI 完整基础:路由、依赖注入、校验、异步和生命周期
- 下一篇:Flask 完整基础:应用工厂、Context、Blueprint、扩展和部署
- 延伸:Python 结构化数据:JSON、CSV、TOML、Schema 与精度边界
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论