类型标注 · __annotations__ · BaseModel · Field · 宽松模式和严格模式 · ValidationError · response_model

类型标注和 Pydantic:Python 的类型只是标签,Pydantic 让它在边界上生效

Java 的类型由编译器保证,Python 的类型标注运行时根本没人看:def f(x: int) 传进字符串照样能跑。Pydantic 的精华是读出这些标注,在类定义时生成一个校验器(Rust 写的 pydantic-core),数据一进模型就按标注转换、检查,不合格就一次报出全部错误。FastAPI 在第 4 站用它把查询串里的 "10" 变成 10,在第 9 站用它把返回值按 response_model 过滤成 JSON。不懂这一点的典型误用:以为写了标注就安全,把外部来的字典到处直接传;或者以为 float | None 就是“可以不传”。

全景:一个 FastAPI 请求的一生 · 第 4 站(还有第 9 站)地图:站 01 · 类型标注和 Pydantic 数据模型以 CPython 3.13、Pydantic 2.14.3、FastAPI 0.162.2 为例

①为什么需要:进出值班助手的数据,两头都不可信

在全景第 4 站,这一步要解决的是:查询串 minutes=10 到了服务端只是字符串 "10",有人会传 0、abc、干脆不传。要在调用端点之前把它变成整数、查范围,不合格就返回 422。第 9 站是出口:端点返回的东西要按约定的结构变成 JSON,多带的内部字段不能漏出去。中间还有一头:第 7 站日志代理返回的 JSON,"pauses": "60" 这种“数字写成字符串”的情况随时会有。

入口 · 第 4 站 ?service=order &minutes=10 按签名校验 "10" → 10,1~60 service="order", minutes=10 minutes=0 → 422,端点不调用 中间 · 第 7、8 站 {"host": "order-01", "pauses": "60", "max_ms": "244.05"} HostGc(**raw) "60" → 60,"244.05" → 244.05 后面的代码可以放心地把它当数字用 出口 · 第 9 站 return {"service": ..., "hosts": [...], "agent_token": "s3cr3t"} response_model=GcSummary 按模型校验、只留模型里有的字段 JSON 里没有 agent_token 三处结果都是本机 FastAPI 实测 实测
需求工作要用,懂机制级。站 03 起,模型返回的 JSON、工具调用的参数,全要用 Pydantic 模型来接;“结构化输出 + 校验失败重试”就建在这一页上。

②没有它会怎样:类型标注不检查,字典什么都收

同一件事,“收下日志代理返回的一台机器的统计”,三种写法并排。

Java 两道关:编译器 + Bean Validation
record HostGc(
    @NotNull String host,
    @PositiveOrZero int pauses,
    Double maxMs) {}

HostGc h = mapper.readValue(json, HostGc.class);
validator.validate(h);   // 另一个步骤
编译期查类型,Jackson 反序列化时转类型,Bean Validation 再查规则,分三步。
裸字典 / 光写标注 能跑
def parse(raw: dict) -> float:
    return raw["max_ms"] * 2

parse({"max_ms": "244.05"})
# → '244.05244.05'  字符串被“乘”了两次

def double(x: int) -> int:
    return x * 2
double("ab")      # → 'abab',标注没拦住
错误不在入口炸,而是带着错的值一路往下走,在离源头很远的地方才出事。
Pydantic 一步:转换 + 校验
class HostGc(BaseModel):
    host: str
    pauses: int = Field(0, ge=0)
    max_ms: float | None = None

h = HostGc(**raw)
h.max_ms * 2      # 488.1,这里一定是 float
不合格在构造时就报错;合格的对象,后面的代码不用再判断类型。

③相邻方案:都能给数据“定个形状”,只有一个会在运行时检查

TypedDict

class HostGc(TypedDict):
    host: str
    max_ms: float

还是普通字典,只给静态检查工具看。

给已有的字典补类型提示。

dataclass

@dataclass
class HostGc:
    host: str
    max_ms: float | None = None

自动生成 __init__,但不转换、不校验:传 "244.05" 进去,存的还是字符串 实测。

内部数据结构,数据来源可信。

Pydantic BaseModel

class HostGc(BaseModel):
    host: str
    max_ms: float | None = None

按标注转换和校验,能导出 JSON 和 JSON Schema。

一切从外部进来、要发出去的数据。

msgspec / attrs

class HostGc(msgspec.Struct):
    host: str
    max_ms: float | None = None

也能校验,更快或更轻,但生态小。

极端性能场景再考虑。

本页学:类型标注本身是什么、Pydantic 怎么把它变成校验器、宽松模式和严格模式、FastAPI 在入口和出口怎么用它。选择规则:边界用 Pydantic,内部用 dataclass。FastAPI、站 03 的结构化输出、站 07 的 MCP SDK 都建在 Pydantic 上。

④原理:标注是留在运行时的元数据,Pydantic 在类定义时把它编译成校验器

先知道这些
  • 类型标注的写法:x: int、def f(x: int) -> float;“可能是 None”写 float | None(3.10 起,等价于老写法 Optional[float]);容器写 list[HostGc]、dict[str, int]。
  • 函数和类都是对象,标注就存在它们身上,见 函数是一等公民。
  • 422 是 HTTP 的“请求格式对,但内容不合格”,FastAPI 用它表示校验失败;Spring 默认用 400。

1类型标注:存在 __annotations__ 里,解释器不看,工具和库来读 ≈ 运行时还在的泛型信息

def double(x: int) -> int: return x * 2 double.__annotations__ {'x': int, 'return': int} double("ab") 解释器不看字典 → 'abab' 实测 谁会去读它 mypy / pyright:运行前静态检查 IDE:补全、跳转、提示 Pydantic、FastAPI:运行时读出来做校验
和 Java 正好反过来:Java 的类型由编译器强制,但泛型参数在运行时被擦除,反射拿不到 List<HostGc> 里的 HostGc;Python 运行时不强制类型,但 list[HostGc] 这个对象原样留着,库可以读出来用。Pydantic 就是靠这一点工作的。

2BaseModel:类定义时生成校验器,构造时一次跑完 ≈ Jackson 反序列化 + Bean Validation 合成一步

① 执行 class 语句

和装饰器一样,在 import 时发生。类体里的 host: str、pauses: int = Field(0, ge=0) 进了 __annotations__。

② 读标注和 Field

BaseModel 的元类把每个字段的类型、默认值、约束收集起来,拼成一份描述,叫 core schema。

③ 编译成校验器

交给 pydantic-core(Rust 写的),生成 SchemaValidator,挂在类上:HostGc.__pydantic_validator__ 实测。

④ 每次构造时校验

HostGc(**raw) 把字典交给校验器:转换、查约束,全部合格才造出对象;否则抛 ValidationError。

$ Q(service=None, minutes=0)
ValidationError: 2 validation errors
  service       string_type
  minutes       greater_than_equal
# 一次报出全部错误,每条带位置 loc 和类型 type
$ HostGc.model_json_schema()["properties"]["pauses"]
{'default': 0, 'minimum': 0,
 'title': 'Pauses', 'type': 'integer'}
# 同一份描述还能导出 JSON Schema,
# FastAPI 的接口文档、站 03 的结构化输出都靠它

和 Java 的差别:Spring 里 Jackson 先把 JSON 变成对象,Bean Validation 再单独查一遍规则,类型不对会在第一步就抛一个 Jackson 异常。Pydantic 把“转换”和“检查”放在同一趟里,所有字段的错误一起报。

3宽松模式和严格模式:默认会帮你转换,但有规矩

默认是宽松模式(lax):"10"、10.0 都能变成 10,但不丢信息的转换才做,10.5 会报错。设成 strict=True 后,类型必须完全对上。默认宽松,是因为 Web 的输入天生是字符串。

Pydantic 实验台:给 minutes: int 喂不同的值,20 个结果全是本机实测

模式
先猜
Lax(minutes="10")

4FastAPI:入口按签名建模型,出口按 response_model 过滤 ≈ @RequestParam + @Valid,和 @ResponseBody

启动时:读签名 service: str → 查询串 minutes: int = Query(10, ge=1, le=60) 启动时:读 response_model GcSummary → 出口校验器 第 4 站:校验参数 不合格:422,端点不调用 第 5~8 站:端点 第 9 站:校验 + 过滤 合格:JSON,只含模型字段 返回值不合格:500 (启动时建好)
入口不合格是调用方的错,所以是 422;出口不合格是你代码的错,所以是 500。⑤ 里两种都会亲眼看到。

5为什么这样设计

Pydantic:在边界“解析”成可信对象选了它

  • 转换和校验一趟做完,出来的对象就是可信的,后面的代码不再到处判断。社区管这叫“解析,而不是校验”(parse, don't validate)。
  • 默认宽松:HTTP 查询串、环境变量、CSV 天生都是字符串,严格模式在这些地方几乎没法用。
  • 代价:每次构造都要跑校验器,所以核心用 Rust 写;宽松转换有时会让人意外,比如 True 变成 1。

运行时强制类型标注没选

  • Python 的设计(PEP 484)明确:标注不在运行时强制,可以逐步加,不加也能跑。
  • 如果解释器每次调用都查类型,所有函数都要变慢,大部分内部调用又根本不需要。
  • 折中就是现在这样:解释器不管,只在数据进出系统的边界上由库来管。

决定胜负的约束:Python 不想为类型检查付运行时成本,也不想强迫每段代码都写类型;但系统边界上的数据必须可信。所以把检查集中到“边界”这一处,由一个读得懂标注、跑得够快的库来做。

最少的语法:Java 怎么写,Python 怎么写 细节、v1 和 v2 的差异放到 ⑥

JavaPython / Pydantic说明
record HostGc(String host)class HostGc(BaseModel): host: str字段就是带标注的类属性
Double maxMs(可以是 null)max_ms: float | None = None“可选”要靠默认值,见 ⑥ 坑 2
@Min(1) @Max(60)Field(10, ge=1, le=60)还有 gt、lt、min_length、pattern
List<HostGc>list[HostGc]嵌套模型逐层校验
mapper.readValue(json, HostGc.class)HostGc.model_validate_json(s)直接从 JSON 字符串校验,比先 json.loads 再校验快
从 Map 构造HostGc(**d) / HostGc.model_validate(d)
mapper.writeValueAsString(h)h.model_dump_json()model_dump() 得到字典
@JsonIgnoreProperties(ignoreUnknown=false)model_config = ConfigDict(extra="forbid")默认是忽略多余字段
ConstraintViolationExceptionValidationError,e.errors()每条有 loc、type、msg
@RequestParam @Min(1) int minutesminutes: int = Query(10, ge=1)FastAPI 端点参数
@ResponseBody GcSummary@app.get(..., response_model=GcSummary)出口校验并过滤

⑤亲眼看到:给值班助手的接口加上模型,入口出口各撞一次墙

还在 gc-helper 项目里,上一页已经 uv add fastapi httpx,Pydantic 跟着装好了(本机 2.14.3)。端点里故意塞了三个问题:日志代理把数字写成字符串、返回值多带一个内部字段、另一个端点返回了不合格的值。

1models.py 两个模型、三个端点
from fastapi import FastAPI, Query
from pydantic import BaseModel, ConfigDict, Field

class HostGc(BaseModel):                          # ≈ 一个 DTO
    model_config = ConfigDict(extra="forbid")    # 多出来的字段直接报错
    host: str
    pauses: int = Field(0, ge=0)
    max_ms: float | None = None
    avg_ms: float | None = None
    error: str | None = None

class GcSummary(BaseModel):
    service: str
    minutes: int
    hosts: list[HostGc]

app = FastAPI()

@app.get("/gc/summary", response_model=GcSummary)
async def gc_summary(service: str, minutes: int = Query(10, ge=1, le=60)):
    raw = {"host": "order-01", "pauses": "60", "max_ms": "244.05", "avg_ms": 26.1}  # 日志代理返回的原始字典
    return {                                     # 故意多带一个内部字段
        "service": service, "minutes": minutes,
        "hosts": [HostGc(**raw), HostGc(host="order-04", error="timeout 1s")],
        "agent_token": "s3cr3t",
    }

@app.get("/gc/leak")                             # 没写 response_model
async def gc_leak(service: str):
    return {"service": service, "agent_token": "s3cr3t"}

@app.get("/gc/bad", response_model=GcSummary)
async def gc_bad(service: str):
    return {"service": service, "minutes": "ten", "hosts": []}
2try_models.py 用 TestClient 打 6 个请求
import warnings; warnings.filterwarnings("ignore")
from fastapi.testclient import TestClient
from models import app
c = TestClient(app, raise_server_exceptions=False)
for p in ("/gc/summary?service=order&minutes=10", "/gc/summary?service=order&minutes=0",
          "/gc/summary?service=order&minutes=abc", "/gc/summary?minutes=10",
          "/gc/leak?service=order", "/gc/bad?service=order"):
    r = c.get(p); print(p, r.status_code, r.text[:230])
$ uv run try_models.py
/gc/summary?service=order&minutes=10 200 {"service":"order","minutes":10,"hosts":[{"host":"order-01","pauses":60,"max_ms":244.05,"avg_ms":26.1,"error":null},{"host":"order-04","pauses":0,"max_ms":null,"avg_ms":null,"error":"timeout 1s"}]}
/gc/summary?service=order&minutes=0 422 {"detail":[{"type":"greater_than_equal","loc":["query","minutes"],"msg":"Input should be greater than or equal to 1","input":"0","ctx":{"ge":1}}]}
/gc/summary?service=order&minutes=abc 422 {"detail":[{"type":"int_parsing","loc":["query","minutes"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"abc"}]}
/gc/summary?minutes=10 422 {"detail":[{"type":"missing","loc":["query","service"],"msg":"Field required","input":null}]}
/gc/leak?service=order 200 {"service":"order","agent_token":"s3cr3t"}
/gc/bad?service=order 500 Internal Server Error

实测 本机 uv 0.13.7、CPython 3.13.18、FastAPI 0.162.2、Pydantic 2.14.3,输出逐字复制。

到这里已经能交差了:入口参数有类型和范围,外部字典进模型才用,出口有 response_model 把关。
已知还没处理的细节
  • 只写了 float | None 却没给默认值,字段变成必填 → ⑥ 坑 2
  • 宽松模式把 True 当成 1 → ⑥ 坑 6
  • 日志一大,一万个对象校验要多久 → ⑥ 第三圈

⑥细节·规范·优化

第一圈的 8 个坑全部在本机复现过 实测。

第一圈

注意事项:不知道就会出事

1以为写了类型标注就会检查← ④-1 解释器不看
现象
def double(x: int),double("ab") 返回 'abab'。
根源
标注只是存在 __annotations__ 里的元数据,普通函数调用不会去读。
做法
外部数据先进 Pydantic 模型;内部代码用 mypy 或 pyright 在 CI 里静态检查。
2float | None 不等于“可以不传”← ④-2 字段规则
现象
max_ms: float | None 不给默认值,A() 报 missing。
根源
类型说的是“值可以是 None”,有没有默认值决定“能不能不传”,两件事分开管。
做法
可以不传就写 max_ms: float | None = None。
3字段名拼错,被悄悄忽略← ④-2 字段规则
现象
H(host="order-01", max_m=244.05) 不报错,得到 max_ms=None,数据丢了。
根源
默认 extra="ignore":多余的键直接丢掉,和 Jackson 关掉 FAIL_ON_UNKNOWN_PROPERTIES 一样。
做法
自己定义的、要严格对齐的模型加 ConfigDict(extra="forbid"),拼错会报 extra_forbidden。
4构造后再赋值,不校验← ④-2 构造时才跑
现象
h.max_ms = "abc" 成功,之后再算平均值才炸。
根源
校验器只在构造时跑,普通赋值就是改属性。
做法
需要时开 ConfigDict(validate_assignment=True),赋值也会报 float_parsing;或者用 model_copy(update=...) 造新对象。
5用 dataclass 接外部数据← ④-1 标注不强制
现象
dataclass 收下 "max_ms": "244.05",字段里存的是 str;同样的数据进 BaseModel 是 float。
根源
dataclass 只根据标注生成 __init__,不转换也不检查。
做法
边界用 BaseModel,内部可信数据才用 dataclass。
6宽松模式的意外:True 变成 1← ④-3 宽松模式
现象
minutes: int 收 True 得到 1,收 " 10 "、"10.0" 都得到 10。
根源
宽松模式只要不丢信息就转换。对查询串很合适,对“模型返回的 JSON”可能掩盖问题。
做法
只对个别字段收紧:Field(strict=True);或整个模型 ConfigDict(strict=True)。用实验台对一下。
7不写 response_model,内部字段漏出去← ④-4 出口过滤
现象
/gc/leak 的响应里有 "agent_token":"s3cr3t"。
根源
没有出口模型,FastAPI 就把返回的字典原样序列化。
做法
每个对外接口都写 response_model,或者把返回类型标注成模型:-> GcSummary。
8照着老教程写 .dict()、parse_obj历史原因
现象
h.dict() 能用,但报 PydanticDeprecatedSince20:“The dict method is deprecated; use model_dump instead”。
根源
Pydantic 2.0(2023 年)改了一批 API,网上和 AI 生成的代码里还有大量 v1 写法。
做法
认准 model_ 开头的新名字:model_dump、model_validate、model_config。

不是坑的一条:模型里写 errors: list[str] = [] 是安全的,Pydantic 会给每个实例复制一份默认值,a.errors.append("x") 后 b.errors 仍是 [] 实测。这和上一页函数默认参数的坑 5正好相反,别把两个规则记混。

第二圈

使用规范:团队里怎么写

外部数据进门先进模型

请求参数、日志代理和模型返回的 JSON、读进来的配置,都先变成 BaseModel 再往下传。

错误值在离源头很远的地方才炸。

对外接口都写 response_model

或者把返回类型标注成模型。内部对象和对外模型分开定义。

内部字段、密码、内网地址漏出去。

“可以不传”就给默认值

x: T | None = None 是固定搭配。

本来可选的字段变成必填,调用方莫名 422。

自己定的协议用 extra="forbid"

接第三方、对方会加字段的接口保持默认 ignore。

字段名拼错,数据悄悄丢失。

只用 v2 的 API

model_dump、model_validate、field_validator。

升级时一片弃用警告,v1 写法以后会删。

内部代码交给静态检查

CI 里跑 mypy 或 pyright,查的是 Pydantic 管不到的内部调用。

坑 1:标注写了却从来没人查。

第三圈

优化和深处

一万台机器的统计,校验要多久

dataclass(不校验)
3.39 ms
TypeAdapter 整批
6.93 ms
validate_json
8.65 ms
json.loads + 整批
12.97 ms
循环里逐个 HostGc(**r)
14.11 ms

一万条 {"host", "max_ms": "244.05"},每种 5 次取最快 实测。整批交给 TypeAdapter(list[HostGc]),只进出 Rust 一次,比 Python 循环里逐个构造快一倍;手里是 JSON 字符串就直接 validate_json,省掉 json.loads 造中间字典。

自定义校验:还是装饰器

class HostGc(BaseModel):
    max_ms: float | None = None

    @field_validator("max_ms", mode="before")
    @classmethod
    def strip_unit(cls, v):
        if isinstance(v, str) and v.endswith("ms"):
            return v[:-2]           # "244.05ms" → "244.05"
        return v

mode="before" 在类型转换之前跑,适合清洗原始输入;@model_validator 能同时看几个字段,比如“有 error 就不该有 max_ms”。@field_validator 是登记型装饰器,类定义时把函数记进 schema,见上一页。

同一份模型,站 03 当结构化输出用

GcSummary.model_json_schema() 导出的 JSON Schema 可以直接交给大模型,让它按这个结构回答;回答回来再 model_validate_json,不合格就带着 e.errors() 让它重试。“定义一次,两头都用”是 Pydantic 在 Agent 工程里这么普及的原因。

版本差异

版本变化
Py 3.9list[int]、dict[str, int] 直接当标注(PEP 585)
Py 3.10float | None 写法(PEP 604)
Pydantic 2.02023 年发布:核心改用 Rust 的 pydantic-core,API 改成 model_*,Config 类改成 model_config
FastAPI 0.100开始支持 Pydantic v2

下一圈

  • Annotated[int, Field(ge=1)]:把约束写进类型里,同一个约束多处复用。
  • 判别联合(discriminated union):一个字段决定用哪个模型,站 04 解析不同的工具调用要用。
  • pydantic-settings:从环境变量和 .env 读配置,≈ @ConfigurationProperties。
  • mypy / pyright 的严格模式和泛型:内部代码的“编译期”检查。
  • FastAPI 的请求体模型、Depends 依赖注入:放到“FastAPI 写接口”那一页。

↺回到⑤:拿⑥改进值班助手

1 · 让日志代理“拼错”一次

把 raw 里的 max_ms 改成 max_m,分别在 extra="forbid" 和默认配置下跑,看哪个会报错、错误在哪个位置。

用到:坑 3,规范“自己定的协议用 forbid”

2 · 直接吃带单位的值

日志代理改成返回 "max_ms": "244.05ms"。照第三圈写 field_validator 让它通过,再写一个 model_validator:error 不为空时 max_ms 必须是 None。

用到:第三圈自定义校验

3 · 结构化输出预演

打印 GcSummary.model_json_schema();再手写一段“模型可能返回的 JSON”,故意让 minutes 是 "ten",用 model_validate_json 接住,把 e.errors() 整理成一句给模型的重试提示。

用到:第三圈、站 03 的前置

1. 用一句话说出这一页的精华。

Python 的类型标注只是留在运行时的元数据,解释器不检查;Pydantic 在类定义时把标注编译成校验器,在数据进出系统的边界上一次完成转换和校验,后面的代码只碰合格的对象。

2. 只写类型标注、用裸字典接外部数据,痛在哪?

标注拦不住任何东西,错的值(比如字符串形式的数字)会一路往下走,在离源头很远的地方才炸;手写校验又长又会漏,还一次只报一个错。

3. 为什么 /gc/bad 是 500,而 minutes=0 是 422?是 ④ 哪条原理?

④-4:入口校验失败是调用方传错了,返回 422;出口按 response_model 校验失败,是端点自己返回了不合格的数据,属于服务端错误,返回 500。

4. 预测:Lax(minutes=" 7") 和 Strict(minutes=7.0) 各会怎样?

前者通过,得到 7:宽松模式会去掉首尾空白再转换(实验台里 " 10 " 就是这样)。后者报 int_type:严格模式下 float 不能当 int,哪怕没有小数部分。

从 ④-3 推出:宽松模式只要不丢信息就转,严格模式要求类型完全对上。

5. 预测:class S(BaseModel): service: str; minutes: int | None,请求体只给了 {"service": "order"},会怎样?

报 missing,位置是 minutes。int | None 只说明值可以是 None,没有默认值它仍然是必填字段。

从 ④-2 推出:类型和“是否必填”分开管,见坑 2。

6. 放回全景:第 4 站前后各发生了什么?如果第 4 站没有 ge=1,有人传 minutes=0,整条链路会怎样?

之前是第 3 站:路由查表找到 gc_summary。之后是第 5 站:端点协程开跑。

没有 ge=1,0 会通过校验,端点照常开跑,第 6 站给 5 台机器各发一个“最近 0 分钟”的请求;日志代理返回空日志,第 8 站算出 0 次停顿(或者用了 max(ms) 就直接 500,见语法对照页的坑 1)。一个本该在 1 毫秒内被 422 挡掉的请求,白白占了 5 个外部调用。

见 全景第 4 站:校验在调用端点之前。