首页/Python 框架指南/Pydantic 数据校验与配置
Python 框架指南 · 第 03 章

Pydantic 数据校验与配置

本章定位:把 Pydantic v2 的常用"零件"一个个拆开讲——怎么定义模型、怎么加约束、怎么写自定义校验、怎么导出数据、怎么读配置、怎么组织 DTO。不搞贯穿工程,目标是学完之后,任何 FastAPI 项目 sch…

约 28 分钟11,343 字23 个代码示例

本章定位:把 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(配置读取),都能直接运行看输出。

下一章:04 · SQLAlchemy 异步引擎与会话