测量并减少 Pydantic 校验开销

原文作者:Pydantic 文档贡献者。本文经授权翻译整理自 Performance tips,按 2026 年 10 月 5 日读取的 latest 文档核对。代码与相关文档采用 MIT 许可证,完整条款随附。

在多数应用里,Pydantic 并不是性能瓶颈。只有实际测量表明校验成本值得优化时,才需要沿着下面的方向调整。比较时应固定数据、模型和版本,并同时检查输入约束、输出结构与错误信息是否仍符合产品要求;运行得更快,但悄悄取消了原本需要的检查,并不是等价优化。

先测量瓶颈,再减少重复构建和转换,最后按需求选择校验语义与错误诊断能力。
原创技术示意图,依据官方建议绘制;表示优化决策顺序,不表示实测加速比例。

通常直接校验 JSON

model_validate(json.loads(...)) 先在 Python 中解析 JSON、创建字典,再把这个 Python 对象交给 Pydantic 校验。相比之下,model_validate_json() 把 JSON 解析与验证交给内部流程,通常更合适。

这并不是没有例外。模型包含 before 或 wrap 校验器时,两步方式在某些情形下可能更快。原文还提到 pydantic-core 当时正在推进相关性能改进,并预期未来直接 JSON 校验能更稳定地占优。这是文档中的开发预期,不应当写成已经在所有版本和模型上成立的保证;应针对实际模型比较两条路径。

复用 TypeAdapter

每次实例化 TypeAdapter 都需要构造校验器和序列化器。若把它放在每次调用都会进入的函数内部,相同结构便会反复支付构造成本。原文用下面的写法说明问题:

from pydantic import TypeAdapter

def my_func():
    adapter = TypeAdapter(list[int])
    # 使用 adapter 做后续工作

对于不会随调用变化的类型,先构造一次,再复用:

from pydantic import TypeAdapter

adapter = TypeAdapter(list[int])

def my_func():
    ...
    # 复用外部的 adapter

这里的省略号是原文的结构示意,不是一段完整业务函数。重点是把稳定 schema 的构造成本移出重复路径;如果类型定义本身会变化,仍应按实际生命周期管理适配器。

已知容器类型时,使用具体类型

声明为 Sequence 时,Pydantic 会检查值是否属于该抽象类型,还可能尝试不同的序列类型,例如 list 与 tuple。如果接口已经规定接收列表或元组,直接声明具体类型可以减少不必要的判断。

Mapping 与 dict 也有类似关系:已知输入是字典时,优先表达这一事实。但改变注解也会改变接口允许接受的对象范围,不能只看基准时间而忽略调用者契约。

确实无需检查的值才使用 Any

Any 会让值保持原样,不对该值施加类型校验。原例很简单:

from typing import Any
from pydantic import BaseModel

class Model(BaseModel):
    a: Any

model = Model(a=1)

这个建议的前提是“本来就不需要校验”。把不可信输入边界上的类型替换为 Any,会取消原有保证;它不是一种免费加速开关。若系统依赖字段类型、嵌套结构或约束来拒绝错误数据,就应保留这些检查。

附加信息使用模型,不要塞进基础类型子类

原文不建议通过继承 str 一类基础类型来附加状态,例如:

class CompletedStr(str):
    def __init__(self, s: str):
        self.s = s
        self.done = False

将数据和状态明确建模,结构更清楚:

from pydantic import BaseModel

class CompletedModel(BaseModel):
    s: str
    done: bool = False

这两种对象的接口并不相同:后一种是含字段的模型,不能当作字符串对象直接替换。调整时要检查下游代码、序列化格式和属性访问方式。

使用带判别字段的联合类型

带标签的联合类型,也称判别联合,用一个字段说明输入属于哪个分支,避免把输入对每种候选结构都尝试一遍。下面是原文的完整示例:

from typing import Any, Literal
from pydantic import BaseModel, Field

class DivModel(BaseModel):
    el_type: Literal['div'] = 'div'
    class_name: str | None = None
    children: list[Any] | None = None

class SpanModel(BaseModel):
    el_type: Literal['span'] = 'span'
    class_name: str | None = None
    contents: str | None = None

class ButtonModel(BaseModel):
    el_type: Literal['button'] = 'button'
    class_name: str | None = None
    contents: str | None = None

class InputModel(BaseModel):
    el_type: Literal['input'] = 'input'
    class_name: str | None = None
    value: str | None = None

class Html(BaseModel):
    contents: DivModel | SpanModel | ButtonModel | InputModel = Field(
        discriminator='el_type'
    )

el_type 的字面量值用于选择分支。这里的 children: list[Any] 仅演示结构,它不会深入校验每个子元素;模型名叫 Html,也不意味着它能净化 HTML 或防止跨站脚本。若把这些字段用于实际渲染,仍需按输出上下文进行转义,并按业务需要把子元素定义成可验证的结构。

不需要模型对象能力时,考虑 TypedDict

嵌套数据若只需要字典结构,可以考虑用 TypedDict 搭配 TypeAdapter,而不是层层实例化 BaseModel。原文给出一个简单微基准,并报告在该结构下,TypedDict 路径约快 2.5 倍:

from timeit import timeit
from typing_extensions import TypedDict
from pydantic import BaseModel, TypeAdapter

class A(TypedDict):
    a: str
    b: int

class TypedModel(TypedDict):
    a: A

class B(BaseModel):
    a: str
    b: int

class Model(BaseModel):
    b: B

ta = TypeAdapter(TypedModel)
result1 = timeit(
    lambda: ta.validate_python({'a': {'a': 'a', 'b': 2}}), number=10000
)
result2 = timeit(
    lambda: Model.model_validate({'b': {'a': 'a', 'b': 2}}), number=10000
)
print(result2 / result1)

这段代码把适配器创建放在计时外,分别计时 10,000 次验证,并输出模型路径耗时与 TypedDict 路径耗时的比值。两条路径的顶层字段分别叫 a 与 b,内层字段结构相似,但输出对象并不等价:一边得到字典,另一边得到模型实例及其嵌套模型。

因此,约 2.5 倍只能描述原文这个微基准,不能推广成所有模型的加速幅度。模型方法、属性访问、序列化能力、配置、校验器和错误诊断需求都可能影响选择。本文完整保留基准代码,未运行,也没有产生本轮计时结果。实际比较时还应记录 Python、Pydantic、pydantic-core 的版本和运行环境。

性能敏感路径谨慎使用 wrap 校验器

Wrap 校验器非常适合复杂校验逻辑,但通常比其他校验器更慢,因为它要求校验中的数据在 Python 层实体化。若测量表明这一层是瓶颈,可以检查是否能用更直接的校验方式表达相同约束。

不能为了去掉 wrap 而改变必须保留的行为。前面 JSON 校验入口的例外也与此相关:应比较完整模型在真实输入下的成本,而不是只比较两个函数名称。

用 FailFast 提前结束失败路径

从 Pydantic 2.8 起,可以给序列类型加上 FailFast 注解。某个元素校验失败后,序列的后续元素便不再继续校验,因此能够缩短失败路径,但也不会收集后面的错误:

from typing import Annotated
from pydantic import FailFast, TypeAdapter, ValidationError

ta = TypeAdapter(Annotated[list[bool], FailFast()])
try:
    ta.validate_python([True, 'invalid', False, 'also invalid'])
except ValidationError as exc:
    print(exc)
    """
    1 validation error for list[bool]
    1
      Input should be a valid boolean, unable to interpret input [type=bool_parsing, input_value='invalid', input_type=str]
    """

列表中索引 1 的 'invalid' 无法解释成布尔值,示例因此只展示这一个错误;后面的 'also invalid' 不会产生额外错误报告。代码里的多行字符串是原文示意输出,并非本文执行所得。

是否接受这种取舍取决于产品要求:有的接口只需要迅速拒绝无效输入,有的表单则希望一次展示全部错误。FailFast 牺牲的是错误可见性,而不是把无效元素当作有效值通过。错误对象也可能包含输入原值,生产日志应避免直接记录敏感数据。

这些优化可以分两类处理:先减少重复构造、无谓转换和不必要的分支尝试,再评估是否真的不需要更丰富的模型或错误信息。每一步都应保留原本需要的数据约束,并以自己的测量判断是否值得采用。

本文仅进行代码静态审查,没有执行源文的验证、基准或打印代码。文档未固定完整的依赖版本,也没有提供独立发表日期。来源中的开发计划、约 2.5 倍结果及版本条件均按原文限定。代码许可证见 sources/LICENSE-MIT.txt(全文同时列于下方)。

原始版权与许可全文

The MIT License (MIT)

Copyright (c) 2017 to present Pydantic Services Inc. and individual contributors.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容