本节目标:把 Pydantic V2 从「会用」推进到「设计得好」,掌握请求/响应模型拆分、约束复用、校验器、序列化取舍与 ORM 集成。
适用版本:Python 3.12+(实测 3.14.6);pydantic 2.13.5、fastapi 0.143.0
7.1 Pydantic V2 模型设计
第 6 章我们把数据库这一层做扎实了:SQLAlchemy 2.1.4 的类型化模型、事务与连接池、Alembic 迁移。但数据库模型(ORM)是「存储视角」的,它带着一堆不该暴露给外部的东西——自增主键、软删除标记、内部状态。真正对外的契约由 Pydantic 模型定义。本节就专门讲怎么把这层模型设计好。
7.1.1 请求模型与响应模型必须分开
新手最容易犯的错,是「一个模型走天下」:拿 ORM 对象直接 return 给前端。这会一次性踩三个坑:
- 泄露内部字段:
hashed_password、is_deleted、internal_note全被序列化出去。 - 耦合存储与契约:数据库改个列名,API 就跟着变,客户端全挂。
- 无法差异化校验:创建时要
name必填,更新时name可选,同一个模型做不到。
正确做法是入参(In)与出参(Out)各建一个模型:
from pydantic import BaseModel, ConfigDict, Field
class ItemIn(BaseModel):
"""客户端提交的创建请求:只允许这些字段,多一个都拒收。"""
model_config = ConfigDict(extra="forbid")
sku: str = Field(pattern=r"^SKU-\d{4}$", examples=["SKU-0001"])
name: str = Field(min_length=1, max_length=64)
price: float = Field(gt=0, examples=[299.0])
class ItemOut(BaseModel):
"""返回给客户端:不含任何内部字段,且补上服务端派生的字段。"""
sku: str
name: str
price: float
currency: str = "CNY"
ItemIn 用 extra="forbid" 把「多余字段」直接挡在门外;ItemOut 则是一个纯粹的「投影」,想加 currency 就加,跟数据库列无关。这两个模型的字段集合可以完全不一致,这正是解耦的意义。
7.1.2 用 Field 加约束,用 Annotated 复用约束
Field 是给单个字段挂约束的地方,最常用的几个:
| 参数 | 适用类型 | 含义 |
|---|---|---|
gt / ge | 数值 | 大于 / 大于等于 |
lt / le | 数值 | 小于 / 小于等于 |
min_length / max_length | 字符串、列表 | 长度下限 / 上限 |
pattern | 字符串 | 正则匹配(注意是部分匹配,要锚定就写 ^...$) |
default_factory | 任意 | 可变默认值(列表、字典必须用它) |
examples | 任意 | 只影响 OpenAPI 文档,不参与校验 |
同一套约束如果在多个字段上重复,抽成 Annotated 类型别名最省事:
from typing import Annotated
from pydantic import BaseModel, Field
# 金额:> 0 且 <= 100 万
Money = Annotated[float, Field(gt=0, le=1_000_000)]
class Item(BaseModel):
price: Money
cost: Money # 复用同一套约束,改一处全生效
Annotated 的第一个参数是真实类型,后面都是「元数据」。Pydantic 会读取这些元数据;静态检查器只看到 float。这样既不丢类型安全,又能集中管理业务约束——金额上限要从 100 万调到 1000 万时,只改 Money 一行。
7.1.3 自定义校验:field_validator 与 model_validator
内置约束覆盖不了的规则,自己写。字段级用 field_validator,整模型级用 model_validator。下面的实测代码把两个都用上了:
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class SKU(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, populate_by_name=True)
sku_id: str = Field(alias="skuId", pattern=r"^SKU-\d{4}$")
name: str = Field(min_length=1, max_length=64)
price: float = Field(gt=0, le=1_000_000)
stock: int = Field(default=0, ge=0)
tags: list[str] = Field(default_factory=list)
@field_validator("tags", mode="before")
@classmethod
def split_csv(cls, v: object) -> object:
# 兼容 "a,b,c" 这种逗号串,先拆再进后续校验
if isinstance(v, str):
return [t.strip() for t in v.split(",") if t.strip()]
return v
@field_validator("name")
@classmethod
def no_reserved_word(cls, v: str) -> str:
if v.lower() == "test":
raise ValueError("name 不能叫 test")
return v
@model_validator(mode="after")
def check_stock_rule(self) -> "SKU":
if self.stock == 0 and "预售" not in self.tags:
raise ValueError("缺货商品必须打上「预售」标签")
return self
关键区别在 mode:
mode="before":在 Pydantic 做类型转换之前运行,输入是原始值(可能是任何类型)。适合「先把 CSV 串拆成列表」这类预处理。mode="after"(field_validator默认):在字段已转成目标类型之后运行,拿到的是干净的值。model_validator(mode="after"):等所有字段都校验完再跑,能访问整个模型,专治跨字段规则(缺货必须带预售标签、结束日期要晚于开始日期)。
实测跑一遍,看真实输出:
== 正常构造 ==
sku_id='SKU-0001' name='机械键盘' price=299.0 stock=5 tags=['gaming', '有线']
dump(默认): {'sku_id': 'SKU-0001', 'name': '机械键盘', 'price': 299.0, 'stock': 5, 'tags': ['gaming', '有线']}
dump(by_alias): {'skuId': 'SKU-0001', 'name': '机械键盘', 'price': 299.0, 'stock': 5, 'tags': ['gaming', '有线']}
== extra=forbid ==
extra_forbidden -> Extra inputs are not permitted
== 跨字段校验 ==
value_error -> Value error, 缺货商品必须打上「预售」标签
注意 tags 传的是字符串 "gaming, 有线",before 校验器把它拆成了列表;str_strip_whitespace 顺手去掉了 name 的首尾空格。校验器负责语义,配置负责格式,各司其职。
7.1.4 model_config:ConfigDict 的四个关键开关
model_config = ConfigDict(...) 是模型的全局行为开关。工程上最该先定下来的四个:
| 开关 | 作用 | 什么时候用 |
|---|---|---|
extra="forbid" | 拒绝未声明的字段 | 所有入参模型,防止客户端塞垃圾 |
str_strip_whitespace=True | 自动去字符串首尾空白 | 用户输入类模型 |
populate_by_name=True | 允许用字段名而非别名填充 | 内部代码用 sku_id、外部用 skuId |
from_attributes=True | 允许从任意对象属性构造 | ORM 转换(见 7.1.7) |
extra 的三个取值要分清楚:forbid 报错、ignore 静默丢弃、allow 原样保留。入参用 forbid,出参可以用 ignore——对客户端宽容、对自己严格,是接口设计的常见取向。
7.1.5 别名:对外字段名与内部字段名解耦
前端习惯 camelCase,Python 习惯 snake_case。别名字段解决这个矛盾:
sku_id: str = Field(alias="skuId")
打开 populate_by_name=True 后,SKU(skuId="SKU-0001", ...) 和 SKU(sku_id="SKU-0001", ...) 都能构造。序列化时用 by_alias=True 就能输出 skuId:
dump(默认): {'sku_id': 'SKU-0001', 'name': '机械键盘', ...}
dump(by_alias): {'skuId': 'SKU-0001', 'name': '机械键盘', ...}
Field 还支持更细的 validation_alias / serialization_alias,可以做到「入参读 skuId、出参写 sku」,但绝大多数项目用 alias + populate_by_name 就够了,不必过度设计。
7.1.6 model_dump 的取舍:一次说清
model_dump 的参数决定「导出的形状」。实测一组对比:
from datetime import datetime, timezone
from pydantic import BaseModel, ConfigDict, Field, computed_field
class Order(BaseModel):
model_config = ConfigDict(from_attributes=True)
order_id: str
qty: int = Field(gt=0)
unit_price: float = Field(gt=0)
coupon: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime(2026, 9, 26, 10, 0, tzinfo=timezone.utc))
@computed_field
@property
def total(self) -> float:
return round(self.qty * self.unit_price, 2)
o = Order(order_id="A-1", qty=3, unit_price=19.9)
各选项的真实输出:
默认: {'order_id': 'A-1', 'qty': 3, 'unit_price': 19.9, 'coupon': None,
'created_at': datetime.datetime(2026, 9, 26, 10, 0, tzinfo=datetime.timezone.utc), 'total': 59.7}
mode=json: {'order_id': 'A-1', ..., 'created_at': '2026-09-26T10:00:00Z', 'total': 59.7}
exclude_none: {'order_id': 'A-1', 'qty': 3, 'unit_price': 19.9, 'created_at': ..., 'total': 59.7}
exclude={'coupon'}: {'order_id': 'A-1', 'qty': 3, 'unit_price': 19.9, 'created_at': ..., 'total': 59.7}
include={'order_id','total'}: {'order_id': 'A-1', 'total': 59.7}
json 字符串: {"order_id":"A-1","qty":3,"unit_price":19.9,"coupon":null,"created_at":"2026-09-26T10:00:00Z","total":59.7}
exclude_unset: {'order_id': 'A-2', 'qty': 1, 'unit_price': 9.0, 'total': 9.0}
逐条解读取舍:
mode="json":把datetime、Decimal、UUID转成 JSON 原生类型。直接给json.dumps用,或返回给 FastAPI 响应,几乎总是要开。exclude_none=True:去掉值为None的字段。适合「可选字段不占位」的响应风格,但注意客户端要能接受字段缺失。exclude/include:按字段名裁剪,做「一个模型、多种视图」时很顺手。exclude_unset=True:只导出显式传入的字段。这是 PATCH 局部更新的核心——客户端只发了name,就只更新name,其余字段不动。
注意最后一行的坑:exclude_unset 输出里仍然有 total,因为 computed_field 永远算「已设置」。派生字段不受 exclude_unset 影响,做局部更新时若不想带上它,得显式 exclude={"total"}。
7.1.7 computed_field:只读派生字段
上面的 total = qty * unit_price 用了 @computed_field。它的特点是:
- 不进
__init__:构造时不能传total,只能由别的字段算出来。 - 出现在 dump 和 JSON Schema 里:对外表现和普通字段一样,前端能读到。
- 始终是只读:不能被赋值。
用它的判断标准很简单:这个值完全由其他字段决定,且不需要存库。金额合计、full_name、is_expired 都属于这一类。反过来,如果它需要独立查询或持久化,就该是一个普通字段。
7.1.8 from_attributes:打通 ORM 到 Pydantic
第 6 章的 SQLAlchemy 模型是普通 Python 对象,属性名和 Pydantic 字段一致。加一行配置,就能直接转换:
class Row: # 模拟 ORM 行对象
def __init__(self) -> None:
self.order_id = "B-9"
self.qty = 2
self.unit_price = 5.0
self.coupon = None
o2 = Order.model_validate(Row()) # 直接吃对象,不用手写 dict
validate(Row) -> order_id='B-9' qty=2 unit_price=5.0 coupon=None created_at=... total=10.0
model_config = ConfigDict(from_attributes=True) 之后,model_validate 既能吃 dict,也能吃「带对应属性的任意对象」——ORM 行、dataclass、SimpleNamespace 都行。这样从数据库查到记录后,ItemOut.model_validate(orm_row) 一步到位,不用 dict(row.__dict__) 那种脆弱的转换。第 6 章讲的 ORM 模型与这里的出参模型就此接上了。
7.1.9 模型设计检查清单
落笔设计一组 API 模型时,按这张表过一遍:
- 入参、出参分开建模型,绝不直接返回 ORM 对象。
- 入参模型统一
extra="forbid";用户输入类模型开str_strip_whitespace。 - 重复约束抽成
Annotated别名,集中管理。 - 跨字段规则写
model_validator(mode="after"),别塞进单个字段校验器。 - 响应序列化走
mode="json";PATCH 用exclude_unset=True并显式排掉 computed 字段。 - 对外字段名与内部不一致时用
alias+populate_by_name。 - 性能敏感、只读的场景可加
ConfigDict(frozen=True, slots=True);但先测量再优化,别过早加。
延伸阅读:Python 类型系统与 Pydantic V2 从类型系统角度讲得更全,本节聚焦「接口模型怎么设计」。
小结
- 请求模型与响应模型必须分开:
extra="forbid"的入参挡垃圾,出参只做投影,杜绝泄露内部字段。 Field管单字段约束,Annotated复用约束;field_validator(before/after)管字段级规则,model_validator(mode="after")管跨字段规则。ConfigDict的extra/str_strip_whitespace/populate_by_name/from_attributes是工程上最该先定的四个开关。model_dump的mode="json"、exclude_none、exclude_unset各有用途;exclude_unset是 PATCH 局部更新的关键,但要注意computed_field不受它影响。computed_field放「由其他字段派生、不需持久化」的值;from_attributes=True让 ORM 行一步转成出参模型。
模型定义好了,它就不只是校验工具,而是可以被机器读取的接口契约。下一节我们就把这层契约导出成 OpenAPI,并据此生成客户端代码。
阅读导航:上一节:Alembic 迁移与数据演进 · 下一节:OpenAPI 契约与客户端代码生成 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。