Python技术迷

你以为你会用 Pydantic?这11条最佳实践让你少走弯路

BaseModel 一把梭,字段一写,model_validate() 一跑,很多人就觉得自己把 Pydantic 用明白了。真到线上,问题往往不是“不会写”,而是“写得太顺了”,顺到默认值埋雷、类型偷偷放水、日志里看着都合法,业务却已经歪了。

我这几年看下来,Pydantic 最容易出问题的地方,不在那些高级特性,恰恰在大家觉得最普通的地方。尤其是接口入参、配置加载、消息消费、定时任务落库这几类场景,一旦模型定义得松,后面排查会很烦。你以为是调用方脏数据,最后一看,是自己模型兜底兜得太热情。

先看一段很常见的写法:

from pydantic import BaseModel

classUserDTO(BaseModel):
    id: int
    name: str = ""
    age: int = 0

这段代码不能说错,但味道已经不太对了。name 和 age 这种字段,真的是“缺了也行”吗?很多时候不是。只是图省事给了默认值,结果调用方少传了字段,系统也不报错,后面你再查“为什么用户年龄都变成 0 了”,就只能翻日志。

第一条,别乱给默认值。默认值不是为了让模型“更稳”,而是明确表达“缺省是合理的”。不合理就别给。

from pydantic import BaseModel, Field

classUserDTO(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, ConfigDict

classJobPayload(BaseModel):
    model_config = ConfigDict(strict=True)

    retry: int
    force: bool

这样 "3" 就不会悄悄变成 3,该报错就报错。脏数据早点炸,比后面业务静默跑偏强。

第四条,区分 Optional 和可不传,不是一回事。这个地方很多人写混。

from typing import Optional
from pydantic import BaseModel

classPatchUserReq(BaseModel):
    nickname: Optional[str] = None

这表示字段可以传 null,也可以不传。但如果你的业务语义是“可以不更新,但一旦传了就不能是 null”,那就别这么写。可以配合 exclude_unset=True 去区分“没传”和“传了空值”。

payload = req.model_dump(exclude_unset=True)

第五条,更新时间、创建时间这类动态值,别直接写在类属性上。不少人写过这种坑:

from datetime import datetime
from pydantic import BaseModel

classTaskLog(BaseModel):
    created_at: datetime = datetime.now()

这不是每次实例化都重新取时间,而是类定义时就定住了。正确写法用 default_factory:

from datetime import datetime
from pydantic import BaseModel, Field

classTaskLog(BaseModel):
    created_at: datetime = Field(default_factory=datetime.now)

第六条,校验逻辑别堆在业务代码里,往模型里收。有些判断如果跟字段本身强相关,就别散落在 service 里,不然同一个规则到处复制。

from pydantic import BaseModel, field_validator

classExportReq(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_validator

classQueryRange(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, ConfigDict

classOrderVO(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 ValidationError

defparse_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, Field

classCreateUserReq(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 最值钱的地方,本来也不是帮你少写几行类型声明,而是让错误尽量死在入口,别活着混进系统里。