类型标注 · __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" 这种“数字写成字符串”的情况随时会有。
②没有它会怎样:类型标注不检查,字典什么都收
同一件事,“收下日志代理返回的一台机器的统计”,三种写法并排。
record HostGc(
@NotNull String host,
@PositiveOrZero int pauses,
Double maxMs) {}
HostGc h = mapper.readValue(json, HostGc.class);
validator.validate(h); // 另一个步骤
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',标注没拦住
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
- Python 运行时不看类型标注。
double("ab")返回'abab',实测。标注只给人、IDE 和 mypy 这类静态检查工具看。 - 外部数据天然是字符串或松散的 JSON:查询串全是字符串,日志代理可能把数字写成字符串。光有标注,什么都拦不住。
- 手写
if not isinstance(...)校验又长又容易漏,而且错误一个一个报,用户要改好几轮。 - 缺的那一块就是这页的精华:在数据进入系统的边界,按标注一次性转换并校验,之后的代码只和“已经合格的对象”打交道。
③相邻方案:都能给数据“定个形状”,只有一个会在运行时检查
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__ 里,解释器不看,工具和库来读 ≈ 运行时还在的泛型信息
List<HostGc> 里的 HostGc;Python 运行时不强制类型,但 list[HostGc] 这个对象原样留着,库可以读出来用。Pydantic 就是靠这一点工作的。2BaseModel:类定义时生成校验器,构造时一次跑完 ≈ Jackson 反序列化 + Bean Validation 合成一步
和装饰器一样,在 import 时发生。类体里的 host: str、pauses: int = Field(0, ge=0) 进了 __annotations__。
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 个结果全是本机实测
4FastAPI:入口按签名建模型,出口按 response_model 过滤 ≈ @RequestParam + @Valid,和 @ResponseBody
5为什么这样设计
Pydantic:在边界“解析”成可信对象选了它
- 转换和校验一趟做完,出来的对象就是可信的,后面的代码不再到处判断。社区管这叫“解析,而不是校验”(parse, don't validate)。
- 默认宽松:HTTP 查询串、环境变量、CSV 天生都是字符串,严格模式在这些地方几乎没法用。
- 代价:每次构造都要跑校验器,所以核心用 Rust 写;宽松转换有时会让人意外,比如
True变成1。
运行时强制类型标注没选
- Python 的设计(PEP 484)明确:标注不在运行时强制,可以逐步加,不加也能跑。
- 如果解释器每次调用都查类型,所有函数都要变慢,大部分内部调用又根本不需要。
- 折中就是现在这样:解释器不管,只在数据进出系统的边界上由库来管。
决定胜负的约束:Python 不想为类型检查付运行时成本,也不想强迫每段代码都写类型;但系统边界上的数据必须可信。所以把检查集中到“边界”这一处,由一个读得懂标注、跑得够快的库来做。
最少的语法:Java 怎么写,Python 怎么写 细节、v1 和 v2 的差异放到 ⑥
| Java | Python / 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") | 默认是忽略多余字段 |
ConstraintViolationException | ValidationError,e.errors() | 每条有 loc、type、msg |
@RequestParam @Min(1) int minutes | minutes: int = Query(10, ge=1) | FastAPI 端点参数 |
@ResponseBody GcSummary | @app.get(..., response_model=GcSummary) | 出口校验并过滤 |
⑤亲眼看到:给值班助手的接口加上模型,入口出口各撞一次墙
还在 gc-helper 项目里,上一页已经 uv add fastapi httpx,Pydantic 跟着装好了(本机 2.14.3)。端点里故意塞了三个问题:日志代理把数字写成字符串、返回值多带一个内部字段、另一个端点返回了不合格的值。
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": []}
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
- 看第一行:
"pauses": "60"变成了60,"244.05"变成了244.05;JSON 里没有agent_token,被response_model过滤掉了。 - 看三个 422 的
loc都以"query"开头,告诉调用方是查询串里哪个参数错了;input是原始字符串"0",证明它在转换之前就是字符串。 - 看没写
response_model的/gc/leak,内部字段原样漏了出去;/gc/bad返回值不合格,是 500 而不是 422。
实测 本机 uv 0.13.7、CPython 3.13.18、FastAPI 0.162.2、Pydantic 2.14.3,输出逐字复制。
⑥细节·规范·优化
第一圈的 8 个坑全部在本机复现过 实测。
注意事项:不知道就会出事
- 现象
def double(x: int),double("ab")返回'abab'。- 根源
- 标注只是存在
__annotations__里的元数据,普通函数调用不会去读。 - 做法
- 外部数据先进 Pydantic 模型;内部代码用 mypy 或 pyright 在 CI 里静态检查。
float | None 不等于“可以不传”← ④-2 字段规则
- 现象
max_ms: float | None不给默认值,A()报missing。- 根源
- 类型说的是“值可以是 None”,有没有默认值决定“能不能不传”,两件事分开管。
- 做法
- 可以不传就写
max_ms: float | None = None。
- 现象
H(host="order-01", max_m=244.05)不报错,得到max_ms=None,数据丢了。- 根源
- 默认
extra="ignore":多余的键直接丢掉,和 Jackson 关掉 FAIL_ON_UNKNOWN_PROPERTIES 一样。 - 做法
- 自己定义的、要严格对齐的模型加
ConfigDict(extra="forbid"),拼错会报extra_forbidden。
- 现象
h.max_ms = "abc"成功,之后再算平均值才炸。- 根源
- 校验器只在构造时跑,普通赋值就是改属性。
- 做法
- 需要时开
ConfigDict(validate_assignment=True),赋值也会报float_parsing;或者用model_copy(update=...)造新对象。
- 现象
- dataclass 收下
"max_ms": "244.05",字段里存的是str;同样的数据进 BaseModel 是float。 - 根源
- dataclass 只根据标注生成
__init__,不转换也不检查。 - 做法
- 边界用 BaseModel,内部可信数据才用 dataclass。
True 变成 1← ④-3 宽松模式
- 现象
minutes: int收True得到1,收" 10 "、"10.0"都得到10。- 根源
- 宽松模式只要不丢信息就转换。对查询串很合适,对“模型返回的 JSON”可能掩盖问题。
- 做法
- 只对个别字段收紧:
Field(strict=True);或整个模型ConfigDict(strict=True)。用实验台对一下。
response_model,内部字段漏出去← ④-4 出口过滤
- 现象
/gc/leak的响应里有"agent_token":"s3cr3t"。- 根源
- 没有出口模型,FastAPI 就把返回的字典原样序列化。
- 做法
- 每个对外接口都写
response_model,或者把返回类型标注成模型:-> GcSummary。
.dict()、parse_obj历史原因
- 现象
h.dict()能用,但报PydanticDeprecatedSince20:“Thedictmethod is deprecated; usemodel_dumpinstead”。- 根源
- 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 再往下传。
错误值在离源头很远的地方才炸。
或者把返回类型标注成模型。内部对象和对外模型分开定义。
内部字段、密码、内网地址漏出去。
x: T | None = None 是固定搭配。
本来可选的字段变成必填,调用方莫名 422。
extra="forbid"接第三方、对方会加字段的接口保持默认 ignore。
字段名拼错,数据悄悄丢失。
model_dump、model_validate、field_validator。
升级时一片弃用警告,v1 写法以后会删。
CI 里跑 mypy 或 pyright,查的是 Pydantic 管不到的内部调用。
坑 1:标注写了却从来没人查。
优化和深处
一万台机器的统计,校验要多久
一万条 {"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.9 | list[int]、dict[str, int] 直接当标注(PEP 585) |
| Py 3.10 | float | None 写法(PEP 604) |
| Pydantic 2.0 | 2023 年发布:核心改用 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。