Python 基础体系 · 第 77/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Pydantic v2:模型、校验器、序列化、Settings 和性能边界

Pydantic 是一个以 Python 类型标注为输入、以运行时数据校验和结构化转换为核心的库。它常用于 HTTP 请求体、配置、消息队列事件、数据库边界和外部 API 响应的建模。

本文以 Python 3.14 的类型语法为背景,讨论 Pydantic v2 的五个核心部分:

  1. 模型如何从类型标注生成校验结构;
  2. 字段校验器和模型校验器如何参与数据流;
  3. Python 对象、字典和 JSON 之间如何序列化;
  4. pydantic-settings 如何加载环境变量、.env 和 Secret;
  5. 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 的含义是:字段的主类型仍然是 strint,同时附带额外的 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

需要区分三类约束:

  1. 类型约束:例如必须是 int
  2. 结构约束:例如必须是 list[int]
  3. 业务约束:例如结束时间必须晚于开始时间。

前两类通常可以直接由类型标注和 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 能更早发现客户端拼写错误;对需要向后兼容的事件协议,ignoreallow 可能更合适。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();不过在包含 beforewrap 模型校验器的特殊场景下,两步方式可能更快,因此最终仍应以实际基准为准。(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. plainwrap:控制内部校验流程

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 文档通常把 dumpserialize 视为相近概念:既可能表示转换为字典,也可能表示转换为 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_noneexclude_defaultsexclude_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]
  • TypedDict
  • dataclass
  • Annotated 类型
  • 联合类型

官方文档特别指出,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. 配置来源的优先级

默认优先级从高到低通常是:

  1. CLI 参数;
  2. Settings(...) 构造器参数;
  3. 环境变量;
  4. .env 文件;
  5. Secret 目录;
  6. 字段默认值。

因此:

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_DATABASEAPP_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 的输入空间更明确。Mappingdict 也有类似差异。官方性能文档建议,在确定输入容器类型时,使用具体的 listtupledict,而不是更宽泛的抽象类型。(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 的全部成本

一个请求的总耗时可以粗略写成:

Trequest=Tnetwork+Tjson+Tvalidation+Tbusiness+Tdatabase+TserializationT_{\text{request}} = T_{\text{network}} + T_{\text{json}} + T_{\text{validation}} + T_{\text{business}} + T_{\text{database}} + T_{\text{serialization}}

其中:

  • TnetworkT_{\text{network}}:网络传输和连接开销;
  • TjsonT_{\text{json}}:JSON 解析和编码;
  • TvalidationT_{\text{validation}}:Pydantic schema 校验;
  • TbusinessT_{\text{business}}:业务逻辑;
  • TdatabaseT_{\text{database}}:数据库或外部服务等待;
  • TserializationT_{\text{serialization}}:响应对象转换和编码。

只有当 TvalidationT_{\text{validation}} 在总耗时中占据显著比例时,优化 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"
}

则不会进入业务函数,而是在请求体校验阶段失败。

这里有两个不同的校验边界:

  1. CreateOrder:客户端输入是否满足接口协议;
  2. OrderResponse:服务端返回是否满足公开响应协议。

输入模型和输出模型不要为了复用代码而强行合并。一个字段可能只允许输入、不应出现在输出中;另一个字段可能由服务端生成、不应由客户端提交。


十三、常见误解与诊断路径

误解一:类型标注会自动阻止错误调用

错误理解:

def f(value: int) -> None:
    ...

就认为运行时一定只能传整数。

事实是:普通 Python 类型标注主要服务静态工具;运行时校验需要 Pydantic 模型、TypeAdaptervalidate_call 或其他显式机制。

误解二:Optional 表示字段可以缺失

str | None 表示允许 None,不等于字段可以省略。需要缺省时提供 = None 或其他默认值。

误解三:model_dump() 一定得到 JSON

model_dump() 默认是 Python 模式,可能包含 datetimetupleset 等非 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_unsetexclude_none 或显式字段集合;
  • 对外响应:使用独立响应模型,防止内部子类字段意外输出。

Pydantic v2 的核心价值不只是“把字典转成对象”,而是把外部数据在进入系统时变成一个有明确结构、有错误路径、有可序列化边界、可生成协议文档并可测量性能的运行时契约。真正可靠的用法,是先设计数据流和信任边界,再选择模型、校验器、Settings source 和序列化方式。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。