Pydantic 数据校验与配置
本章定位:把 Pydantic v2 的常用"零件"一个个拆开讲——怎么定义模型、怎么加约束、怎么写自定义校验、怎么导出数据、怎么读配置、怎么组织 DTO。不搞贯穿工程,目标是学完之后,任何 FastAPI 项目 sch…
本章定位:把 Pydantic v2 的常用"零件"一个个拆开讲——怎么定义模型、怎么加约束、怎么写自定义校验、怎么导出数据、怎么读配置、怎么组织 DTO。不搞贯穿工程,目标是学完之后,任何 FastAPI 项目 schema 目录里那堆模型,你都能一眼看懂、上手就改。
Pydantic 是 FastAPI 的御用校验库:你声明的请求体、响应体、查询参数、应用配置,背后全是它在干活。这一章的所有示例,都围绕虚构的内容平台「墨集 inkhub」展开——文章(Article)、作者(Author)、栏目(Column)。
1. BaseModel:给数据做个模具
作用:BaseModel 是一切的地基。继承它、用类型标注声明字段,一个"自带质检的数据类"就诞生了——数据进来先过安检,不合格直接弹出去。
最小示例:
from pydantic import BaseModel
class Author(BaseModel):
author_id: int
name: str
email: str
实例化时数据被自动检查:
a = Author(author_id=1, name="林墨", email="linmo@inkhub.cn")
# 这两个都会抛 ValidationError:
# Author(author_id="abc", name="林墨", email="x@inkhub.cn") # "abc" 转不成 int
# Author(author_id=1, name="林墨") # 缺 email
比喻:BaseModel 就像月饼模具——面团(原始数据)按进去,出来的一定是模具规定的形状;形状不对的面团,当场弹回给你,绝不带病出厂。
常见坑:
- 多传了模型里没有的字段,v2 默认静默忽略。想让"多传字段"也报错:
from pydantic import BaseModel, ConfigDict
class Author(BaseModel):
model_config = ConfigDict(extra="forbid")
author_id: int
name: str
ValidationError不会自动变成 HTTP 422。只有在 FastAPI 的请求链路里(声明为参数类型时)才自动转换;自己脚本里调用,得自己try/except。
2. Field:给字段挂一块规则标牌
作用:类型标注只管"大类型对不对";细粒度规则交给 Field——必填与否、数值范围、字符串长度、文档元信息,一个全管。
常用参数速查:
| 参数 | 作用 |
|---|---|
...(第一个位置参数) |
必填标记,表示没有默认值 |
default |
默认值 |
default_factory |
可变默认值的工厂函数 |
gt / ge / lt / le |
数值边界:大于 / 大于等于 / 小于 / 小于等于 |
min_length / max_length |
长度约束(字符串、列表) |
pattern |
正则约束(字符串) |
alias |
字段别名(接收/导出时用别的名字) |
title / description |
文档标题与说明(给 OpenAPI 文档看) |
examples |
示例值列表(展示在 API 文档里) |
repr |
是否出现在 repr 输出中(密码类字段设 False) |
最小示例(墨集的文章字段):
from pydantic import BaseModel, Field
class Article(BaseModel):
article_id: int | None = Field(
default=None,
title="文章ID",
description="文章唯一标识,创建时可不传,由服务端生成。",
ge=1,
examples=[1, 42],
)
title: str = Field(
...,
title="标题",
description="文章标题,长度 2 到 50 个字符。",
min_length=2,
max_length=50,
examples=["凌晨四点的墨尔本"],
)
read_count: int = Field(default=0, ge=0, description="阅读量,不能为负")
tags: list[str] = Field(default_factory=list, examples=["散文", "旅记"])
... 到底是什么:Python 内置单例 Ellipsis。放在 Field 的第一个位置参数,意思就是"此字段必填、没有默认值"。写 title: str = Field(...) 等价于 title: str,但可以接着塞 min_length 这些参数。
常见坑:
title/description/examples不参与校验,只影响/docs页面的展示,别指望它们拦截脏数据。- 可变默认值禁止直接给:
tags: list[str] = Field(default=[])会让所有实例共享同一个列表,必须default_factory=list。 ge / le只管数值;字符串长度是min_length / max_length;正则是pattern。别拿ge去约束字符串长度。
3. 类型转换:一位宽松的翻译官
作用:Pydantic 默认是"宽松模式"(lax/smart 模式)——能安全转的值自动帮你转。Web 世界里表单和查询参数天生全是字符串,全靠这个行为兜底。
from pydantic import BaseModel
class Follow(BaseModel):
follower_id: int
remark: str
f = Follow(follower_id="8", remark="三年老读者")
print(f.follower_id, type(f.follower_id).__name__) # 8 int
# Follow(follower_id=1, remark=42) # ValidationError!v2 不做 int → str
数字字符串 "8" → int 8 自动完成;字符串里混空格(" 8 ")也会先 strip 再转。
但注意这个转换是"偏科"的:数字字符串能转数值,数值不能转字符串(remark=42 直接报错)。v1 老项目里 int → str 是放行的,这是迁移到 v2 的高频翻车点。
常见坑:
- bool 的转换规则要背:
"true" / "false" / "1" / "0" / "on" / "off" / "yes" / "no"(大小写不敏感)都能转;其余("大概吧")直接报错。别用字符串玩"薛定谔的布尔"。 - 想要"严丝合缝"模式,两种写法:
from pydantic import BaseModel, ConfigDict, StrictInt
class Stats(BaseModel):
model_config = ConfigDict(strict=True) # 整个模型严格模式
read_count: int # 传 "8" 直接报错
class Stats2(BaseModel):
read_count: StrictInt # 只对单个字段严格
- 依赖宽松转换时要心里有数:
"1.9"能转 float,"abc"转任何数值类型都报错;日期类(datetime)接受"2026-08-27T10:00:00"这类 ISO 字符串。
4. Optional 与默认值:字段可以缺席吗
作用:一个字段能不能"不传",看两点:类型是否 X | None、有没有默认值。二者缺一不可(想要可缺省的话)。
from pydantic import BaseModel, Field
class Author(BaseModel):
author_id: int # 必填:缺了就报错
name: str # 必填
bio: str | None = None # 可缺省:不传就是 None
pen_name: str = "佚名" # 可缺省:不传就是 "佚名"
tags: list[str] = Field(default_factory=list) # 可缺省 + 工厂默认值
比喻:必填字段像飞机上的安全带——登机(实例化)时少一条都不放行;默认值像座椅靠背上的毯子,你自己带了用你的,没带就发一条标准款。
常见坑:
bio: str | None(不带= None)仍然是必填!调用方必须显式传bio=None才行。想"可不传",默认值不能省。Optional[str]和str | None是一回事;新代码推荐X | None写法(Python 3.10+ 原生支持)。- 默认值写成
bio: str | None = None还是Field(default=None),效果一样,前者更简洁。
5. 嵌套模型:大箱套小盒
作用:字段类型可以是另一个 BaseModel,也可以是它们的列表/字典。嵌套校验自动递归执行,错误信息带完整路径。
from pydantic import BaseModel, Field
class Author(BaseModel):
author_id: int
name: str
class Column(BaseModel):
column_id: int
name: str
class Article(BaseModel):
title: str = Field(min_length=2, max_length=50)
author: Author # 嵌套单个
columns: list[Column] = Field(default_factory=list) # 嵌套列表
article = Article(
title="凌晨四点的墨尔本",
author={"author_id": 1, "name": "林墨"}, # 直接塞 dict 也可以
columns=[{"column_id": 3, "name": "旅记"}],
)
print(article.author.name) # 林墨 —— 已经是 Author 实例
print(article.columns[0].name) # 旅记
比喻:嵌套模型是快递大箱套小盒——过安检时外箱扫一遍,里面每个小盒还会单独开箱检查。
常见坑:
- 嵌套字段传字典即可,Pydantic 自动转成子模型实例,访问用点号链。
- 定位错误看
loc:("author", "name")表示问题出在嵌套字段author的name里。 - 嵌套列表默认值的老问题:
Field(default_factory=list),永远别裸写= []。
6. 自定义校验器:内置规则不够用了
类型和数值范围管不住的业务规则(比如"标题不许是标题党"),用校验器自己写。
6.1 @field_validator:管一个字段
作用:对单个字段做自定义检查或数据清洗。
签名:
@field_validator("字段名", mode="after")
@classmethod
def check(cls, v: 字段类型) -> 字段类型: ...
mode="after"(默认):在类型转换之后执行,拿到的是转换后的值(str就是str)。mode="before":在类型转换之前执行,拿到的是原始值(可能是任意类型),适合做预处理。
最小示例:
from pydantic import BaseModel, field_validator
class Article(BaseModel):
title: str
slug: str # URL 片段,如 "melbourne-at-4am"
@field_validator("title")
@classmethod
def strip_title(cls, v: str) -> str:
return v.strip() # 清洗:去首尾空格
@field_validator("title")
@classmethod
def reject_clickbait(cls, v: str) -> str:
if "震惊" in v or "惊呆" in v:
raise ValueError("墨集禁止标题党")
return v
@field_validator("slug")
@classmethod
def slug_format(cls, v: str) -> str:
if not v.islower() or " " in v:
raise ValueError("slug 必须全小写且不含空格")
return v
常见坑:
- 必须 return!校验器忘了返回值,字段值会静默变成
None——新手重灾区,出问题了先查这个。 - 抛
ValueError/AssertionError即可,Pydantic 会自动包装成漂亮的ValidationError;不要自己抛 ValidationError。 - 同一字段的多个校验器按定义顺序执行,前一个的输出是后一个的输入。
- v2 中校验器要求第一个参数是
cls,记得配@classmethod。
6.2 @model_validator:字段之间要联动
作用:校验逻辑需要同时看多个字段时用模型级校验。典型场景:"status 是 published 时,published_at 必填"。
签名:
@model_validator(mode="after")
def check(self) -> "Self": ...
最小示例:
from pydantic import BaseModel, model_validator
class Article(BaseModel):
title: str
status: str = "draft" # draft / published
published_at: str | None = None
@model_validator(mode="after")
def publish_needs_time(self) -> "Article":
if self.status == "published" and not self.published_at:
raise ValueError("已发布的文章必须有发布时间")
return self
mode="after":拿到的是已构造好的self,返回self。mode="before":拿到的是原始输入 dict(参数习惯命名data),处理完返回 dict,适合"字段改名/补默认值"这类预处理。
常见坑:
- after 模式下别忘了
return self,否则模型直接变成None。 - 字段级校验先跑、after 模型的 model_validator 后跑,所以
self.status一定是已通过校验的值。
7. 导出:model_dump / exclude_unset / from_attributes
数据进得来,还得出得去。这三个 API 是 DTO 层的三大主力。
7.1 model_dump:模型 → 字典
作用:把模型导出为 Python 字典;model_dump_json() 直接导出 JSON 字符串。
article.model_dump() # 全量导出
article.model_dump(exclude={"read_count"}) # 排除指定字段
article.model_dump(exclude_none=True) # 跳过值为 None 的字段
article.model_dump(mode="json") # datetime 等转成 JSON 兼容类型
常见坑:model_dump() 里的 datetime、UUID 还是 Python 对象,直接 json.dumps 会炸;要么 mode="json",要么用 model_dump_json()。
7.2 exclude_unset:PATCH 接口的灵魂
作用:只导出"用户这次明确传了"的字段,没传的一律不出现在结果里。
from pydantic import BaseModel
class ArticleUpdate(BaseModel):
title: str | None = None
summary: str | None = None
patch = ArticleUpdate(title="新标题")
patch.model_dump(exclude_unset=True) # {'title': '新标题'} ← 只含传了的
patch.model_dump() # {'title': '新标题', 'summary': None} ← 惨案
第二种导出方式拿去更新数据库,会把用户压根没提的 summary 直接覆盖成 None——数据丢了都没处哭。所以 PATCH 更新永远配 exclude_unset=True。
7.3 from_attributes:从 ORM 对象直接"吸"数据
作用:让模型可以直接从任意对象身上按属性名取值(典型对象:SQLAlchemy 的 ORM 实例)。Response 模型标配。
两种写法:
from pydantic import BaseModel, ConfigDict
class ArticleResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) # 写法一:模型级开关
article_id: int
title: str
# 写法二:单次调用时指定
# resp = ArticleResponse.model_validate(orm_article, from_attributes=True)
常见坑:模型字段名和 ORM 属性名对不上时"吸"不到值,校验报"字段缺失"。要么改字段名对齐,要么给 ORM 实例加同名 property。
8. pydantic-settings:把配置也交给 Pydantic 管
作用:BaseSettings 能自动从环境变量和 .env 文件读取配置,并且照常走类型校验。配置值写错,应用启动那一刻就报错,而不是运行到一半突然崩。
安装(它不在 fastapi 的默认依赖里):
pip install pydantic-settings
# uv 项目则是:uv add pydantic-settings
最小示例:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "墨集 API"
debug: bool = True
settings = Settings()
print(settings.app_name)
读取优先级(高 → 低):环境变量 > .env 文件 > 字段默认值。同一个键,环境变量永远赢。
8.1 读 .env 文件
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "墨集 API"
model_config = SettingsConfigDict(env_file=".env")
对应的 .env:
INKHUB_APP_NAME=墨集 inkhub 内容平台
比喻:BaseSettings 像个贴身管家——你出门前只说一句"按惯例准备"(默认值),它先翻你留在桌上的便签(.env),再听你当面吼的那句(环境变量),当面的话永远最优先。
8.2 env_prefix:多项目共处一室
作用:给本类的所有字段名统一加前缀,避免环境变量撞名。同一台机器上跑多个服务时必备。
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "墨集 API" # 实际读取 INKHUB_APP_NAME
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="INKHUB_",
)
8.3 嵌套配置:分组管理
配置项一多,全平铺会很乱。用嵌套模型分组,配合 env_nested_delimiter:
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict
class Database(BaseModel): # 内层是普通 BaseModel,不是 BaseSettings
host: str = "127.0.0.1"
port: int = 3306
class Settings(BaseSettings):
db: Database = Database()
model_config = SettingsConfigDict(
env_file=".env",
env_prefix="INKHUB_",
env_nested_delimiter="__", # INKHUB_DB__HOST=10.0.0.8
)
.env 里写 INKHUB_DB__HOST=10.0.0.8,就会精准命中 settings.db.host。
常见坑:
- 环境变量优先级高于 .env。CI/容器里导出的变量会悄悄覆盖本地 .env,排查配置问题先怀疑这个。
env_file=".env"的路径相对运行目录(你在哪儿执行命令),不是相对 py 文件所在目录。从项目根启动、文件在子目录时,要写相对根目录的路径。.env通常含敏感信息,记得进.gitignore;仓库里提交一份去掉敏感值的.env.example当模板。- 环境变量本质全是字符串,bool 字段靠 Pydantic 转换:
true / 1 / on / yes都行,maybe不行。
9. DTO 设计模式:Create / Update / Response 三兄弟
作用:同一个"文章"在生命周期的不同阶段长得不一样——创建时没有 article_id、更新时全部字段可缺省、返回时不该带内部字段。硬用一个模型通吃,校验规则会互相打架。解法:按用途拆成模型家族。
from pydantic import BaseModel, ConfigDict, Field
# —— 入参:创建文章 ——
class ArticleCreate(BaseModel):
title: str = Field(..., min_length=2, max_length=50)
summary: str | None = Field(None, max_length=200)
author_id: int = Field(..., ge=1)
# —— 入参:更新文章(PATCH:全部可缺省)——
class ArticleUpdate(BaseModel):
title: str | None = Field(None, min_length=2, max_length=50)
summary: str | None = Field(None, max_length=200)
status: str | None = None
# —— 出参:响应模型(吸 ORM 属性)——
class ArticleResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
article_id: int
title: str
status: str
author_id: int
配套的路由用法(示意,FastAPI 部分第 2 章已讲):
from fastapi import FastAPI
app = FastAPI()
@app.post("/articles", response_model=ArticleResponse)
async def create_article(data: ArticleCreate) -> ArticleResponse:
... # 入库后返回 ORM 对象,FastAPI 用 response_model 自动序列化+过滤
@app.patch("/articles/{article_id}")
async def update_article(article_id: int, data: ArticleUpdate) -> dict:
updates = data.model_dump(exclude_unset=True) # 只取用户传的字段
... # 按字段精确更新
return {"updated": list(updates)}
分工一览:
| 模型 | 职责 | 关键手段 |
|---|---|---|
Create |
创建入参,必填字段齐全 | Field(...) + 约束 |
Update |
更新入参,字段全部可缺省 | X \| None = None + exclude_unset |
Response |
出参,控制暴露哪些字段 | from_attributes + 路由 response_model |
常见坑:
- 别图省事让接口直接返回 ORM 对象而不配
response_model——等于把内部字段全裸奔给前端。 - Update 模型"全员可缺省"和"用
model_dump()全量导出"组合起来会误清数据,记住:PATCH 更新必配exclude_unset=True。 - 家族三兄弟可以共享一个基类,再用继承裁剪字段,避免三处复制粘贴。
10. 小结
这一章的 API 手册浓缩成一张表:
| 场景 | 用什么 |
|---|---|
| 定义数据结构 | BaseModel + 类型标注 |
| 字段级规则 | Field(... / ge / le / min_length / max_length / default_factory) |
| 字段可缺省 | X \| None = None(类型 + 默认值,缺一不可) |
| 复杂结构 | 嵌套模型 + list[子模型] |
| 单字段自定义规则 | @field_validator(记得 return) |
| 多字段联动规则 | @model_validator(mode="after") |
| 模型导出 | model_dump / model_dump_json |
| PATCH 局部更新 | model_dump(exclude_unset=True) |
| ORM → 响应 | ConfigDict(from_attributes=True) |
| 读取配置 | BaseSettings + env_file / env_prefix / env_nested_delimiter |
| 请求/响应分家 | Create / Update / Response 模型家族 |
配套演示脚本在 demo/ 目录:demo_model.py(校验器与导出全家桶)、demo_settings.py(配置读取),都能直接运行看输出。