你以为你会用 Pydantic?这11条最佳实践让你少走弯路
BaseModel 一把梭,字段一写,model_validate() 一跑,很多人就觉得自己把 Pydantic 用明白了。真到线上,问题往往不是“不会写”,而是“写得太顺了”,顺到默认值埋雷、类型偷偷放水、日志里看着都合法,业务却已经歪了。
我这几年看下来,Pydantic 最容易出问题的地方,不在那些高级特性,恰恰在大家觉得最普通的地方。尤其是接口入参、配置加载、消息消费、定时任务落库这几类场景,一旦模型定义得松,后面排查会很烦。你以为是调用方脏数据,最后一看,是自己模型兜底兜得太热情。
先看一段很常见的写法:
from pydantic import BaseModelclassUserDTO(BaseModel):
id: int
name: str = ""
age: int = 0
这段代码不能说错,但味道已经不太对了。name 和 age 这种字段,真的是“缺了也行”吗?很多时候不是。只是图省事给了默认值,结果调用方少传了字段,系统也不报错,后面你再查“为什么用户年龄都变成 0 了”,就只能翻日志。
第一条,别乱给默认值。默认值不是为了让模型“更稳”,而是明确表达“缺省是合理的”。不合理就别给。
from pydantic import BaseModel, FieldclassUserDTO(BaseModel):
id: int
name: str = Field(..., min_length=1)
age: int = Field(..., ge=0, le=150)
第二条,能约束就别只写裸类型。str、int 太宽了,线上脏数据比你想得勤快。尤其是金额、分页、状态码、手机号长度这类字段,最好在模型层先卡一道,不然后面每层都得补 if。
第三条,别指望 Pydantic 自动帮你理解业务语义。比如 "0" 转 int,"true" 转 bool,它确实能转,但有些场景我不太喜欢这种“好心办坏事”。消费 MQ、接外部接口时,数据越自动纠正,越容易把问题拖到后面。
这种时候我一般直接开严格模式:
from pydantic import BaseModel, ConfigDictclassJobPayload(BaseModel):
model_config = ConfigDict(strict=True)
retry: int
force: bool
这样 "3" 就不会悄悄变成 3,该报错就报错。脏数据早点炸,比后面业务静默跑偏强。
第四条,区分 Optional 和可不传,不是一回事。这个地方很多人写混。
from typing import Optional
from pydantic import BaseModelclassPatchUserReq(BaseModel):
nickname: Optional[str] = None
这表示字段可以传 null,也可以不传。但如果你的业务语义是“可以不更新,但一旦传了就不能是 null”,那就别这么写。可以配合 exclude_unset=True 去区分“没传”和“传了空值”。
payload = req.model_dump(exclude_unset=True)
第五条,更新时间、创建时间这类动态值,别直接写在类属性上。不少人写过这种坑:
from datetime import datetime
from pydantic import BaseModelclassTaskLog(BaseModel):
created_at: datetime = datetime.now()
这不是每次实例化都重新取时间,而是类定义时就定住了。正确写法用 default_factory:
from datetime import datetime
from pydantic import BaseModel, FieldclassTaskLog(BaseModel):
created_at: datetime = Field(default_factory=datetime.now)
第六条,校验逻辑别堆在业务代码里,往模型里收。有些判断如果跟字段本身强相关,就别散落在 service 里,不然同一个规则到处复制。
from pydantic import BaseModel, field_validatorclassExportReq(BaseModel):
file_type: str
limit: int
@field_validator("file_type")
@classmethod
defcheck_file_type(cls, v: str):
if v notin {"csv", "xlsx"}:
raise ValueError("file_type 只支持 csv/xlsx")
return v
@field_validator("limit")
@classmethod
defcheck_limit(cls, v: int):
if v > 50000:
raise ValueError("单次导出不能超过50000")
return v
第七条,跨字段判断,用 model_validator,别写成半残废校验。比如开始时间不能大于结束时间,这种事校验单个字段是做不干净的。
from datetime import datetime
from pydantic import BaseModel, model_validatorclassQueryRange(BaseModel):
start_at: datetime
end_at: datetime
@model_validator(mode="after")
defvalidate_range(self):
if self.start_at > self.end_at:
raise ValueError("start_at 不能晚于 end_at")
return self
第八条,别把 ORM 对象、字典、外部响应混着喂,输入边界要清楚。尤其项目一大,谁都能往模型里塞东西,最后调试很痛苦。Pydantic v2 里如果要接 ORM,对应配置就显式写,不要靠猜。
from pydantic import BaseModel, ConfigDictclassOrderVO(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
amount: int
第九条,**序列化时别无脑 model_dump()**。有些字段是内部态,不该透给前端;有些空值、默认值发出去纯属污染响应。
data = order.model_dump(
exclude={"deleted", "inner_remark"},
exclude_none=True
)
我见过最离谱的一次,是内部重试次数、补偿状态直接跟着响应一起返回了。代码没报错,接口也 200,就是看着别扭。这种别扭,经验多了第一眼就会警觉。
第十条,给错误信息留点可读性,别让日志只有 ValidationError 大段栈。消费批量数据时,我一般会把原始片段和错误位置一起打出来,不然后面根本不知道是哪条坏了。
from pydantic import ValidationErrordefparse_rows(rows: list[dict]):
result = []
for idx, row in enumerate(rows, start=1):
try:
result.append(UserDTO.model_validate(row))
except ValidationError as e:
print(f"row={idx} invalid, data={row}, err={e.errors()}")
return result
第十一条,模型要分层,别一个 BaseModel 从入参顶到数据库再顶到返回值。这个毛病特别常见,图省事,最后一个模型背三种职责。接口想加字段,数据库不想加;数据库字段是内部含义,前端又不该看。揉一起,迟早打架。
我一般至少拆三层:请求模型、领域处理模型、响应模型。看着啰嗦,真改需求时省心很多。
from pydantic import BaseModel, FieldclassCreateUserReq(BaseModel):
name: str = Field(..., min_length=1)
mobile: str = Field(..., min_length=11, max_length=11)
classUserEntity(BaseModel):
id: int
name: str
mobile: str
status: int
classUserResp(BaseModel):
id: int
name: str
Pydantic 这东西,越往后用,越不是“会不会写模型”的问题,而是你拿它卡数据边界的手够不够狠。模型一松,脏数据就进来了;模型一乱,业务语义就糊了;模型一锅炖,后面改接口、补日志、查线上,全是反复活。
很多弯路不是框架坑你,是你太相信“能跑就行”。Pydantic 最值钱的地方,本来也不是帮你少写几行类型声明,而是让错误尽量死在入口,别活着混进系统里。