接口真正麻烦的地方,往往不是把一个函数挂到路径上,而是回答一连串很具体的问题:标题最短几个字?价格能不能是负数?难度允许哪些值?客户端多传了一个字段怎么办?更新时没传的字段要不要清空?数据库对象里的密码哈希会不会跟着响应一起出去?
如果这些规则散在路由函数的 if 判断、数据库代码和前端约定里,接口刚开始还能跑,改上几轮就容易互相对不上。Pydantic 模型解决的正是这个问题。我们把数据的类型、边界、结构和转换方式写在模型里,FastAPI 就能在调用路由函数之前检查请求,并在返回响应之前检查和过滤结果。

这一章会一直围绕一个“课程目录接口”推进。我们先让创建课程跑起来,再逐步加上字段约束、枚举、嵌套列表、别名和验证器;随后实现部分更新,最后用响应模型挡住内部字段。每加一条规则,我们都观察请求成功时发生了什么,失败时 422 又把错误定位到了哪里。
本章代码按 Pydantic v2 编写。你会看到 model_dump()、field_validator、model_validator 和 ConfigDict 等 v2 接口,不需要把它们改写成旧版本语法。
客户端提交的数据属于不可信输入。即使调用方也是我们自己的前端,也可能因为旧版本、缓存、手工调试或程序错误,发来缺字段、错类型、越界值和多余字段。请求模型的任务,是定义什么数据可以进入业务函数。
响应模型处理的是另一个方向。路由函数拿到数据库记录之后,内部对象可能比公开接口丰富得多。响应模型定义我们承诺返回什么,同时把没有声明的字段挡在服务端。请求模型和响应模型看起来会有重复字段,但职责完全不同,所以不要为了少写几行代码,就拿同一个模型包办所有方向。
先看一个最小版本:
from fastapi import FastAPI, status
from pydantic import BaseModel
app = FastAPI(title="课程目录接口")
class CourseCreate(BaseModel):
title: str
price_yuan: int
class CoursePublic(BaseModel):
id: int
title: str
price_yuan: int
@app.post(
"/courses",
response_model=CoursePublic,
status_code=status.HTTP_201_CREATED,
)
def create_course(payload: CourseCreate) -> dict:
return {
"id": 1,
**payload.model_dump(),
"internal_notes": "等待编辑复核",
}这里的 payload: CourseCreate 不是普通的类型提示。FastAPI 会读取它,把请求体交给 Pydantic。只有数据能构造成 CourseCreate,路由函数才会执行。函数上的 response_model=CoursePublic 则建立了输出边界:虽然返回字典里有 internal_notes,客户端仍然只能看到 id、title 和 price_yuan。
BaseModel 的另一个作用,是把通过校验的数据变成有明确属性的 Python 对象。进入函数以后,我们写的是 payload.title,不再需要到原始字典里反复取键。payload.model_dump() 会递归地把模型转换为字典,后面保存数据和应用补丁时都会用到它。
Pydantic 默认会忽略模型中没有声明的额外输入。公开接口如果不希望悄悄吞掉客户端拼错的字段,应显式设置 extra="forbid"。这样,拼错字段名会立即变成可以定位的校验错误。
“字符串”和“整数”只说明了数据的大类,还没有表达业务允许的范围。一门课程的标题不能是空字符串,价格不能小于零,标签数量也不该无限增长。Field 可以把这些边界放到字段声明旁边。
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field
ShortText = Annotated[str, Field(min_length=2, max_length=40)]
class CourseCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
title: Annotated[str, Field(min_length=3
Annotated 可以理解成“类型加元数据”。在类型检查器看来,price_yuan 仍然是 int;Pydantic 会继续读取后面的 Field(ge=0, le=9999),把它变成运行时校验规则和接口文档里的约束。这样写还有一个实用好处:ShortText 可以重复使用,标签、作者姓名等短文本不必各写一遍长度限制。
注意列表上有两层规则。tags 外层的 Field(min_length=1, max_length=5) 限制列表中有几项;list[ShortText] 里的 ShortText 限制每一项字符串有多长。把约束放错层级,得到的就是完全不同的契约。

下面四种声明很像,但含义不同:
from pydantic import BaseModel
class Example(BaseModel):
required_text: str
required_but_nullable: str | None
optional_with_none_default: str | None = None
optional_with_value_default: int = 10required_text 必须提供,而且不能为 null。required_but_nullable 也必须提供,只是值允许为 null。后两项可以不提供,因为它们有默认值。也就是说,类型中出现 None 只代表“值可以为空”,不会自动把字段变成“可以省略”。是否可以省略,取决于有没有默认值。
这一区别在创建接口和更新接口里都很关键。创建课程时,标题通常既必填又不允许为空;部分更新时,标题可以不传,但只要传了,往往仍然不允许是 null。后面实现 PATCH 时,我们会把这两个状态分开处理。
课程难度如果只允许“入门、中级、高级”三种状态,直接写 str 就太宽了。"expert"、"middle" 或一个拼错的单词都能通过类型检查,错误只能拖到业务逻辑里才暴露。
from enum import Enum
class CourseLevel(str, Enum):
beginner = "beginner"
intermediate = "intermediate"
advanced = "advanced"
class CourseCreate(BaseModel):
level: CourseLevel枚举成员有稳定的名称和值。Pydantic 会检查输入是否属于这组值,FastAPI 生成的接口文档也会展示可选项。路由函数拿到的是 CourseLevel 成员,序列化成 JSON 时则会输出对应字符串。
如果客户端发送:
{
"level": "expert"
}FastAPI 会在进入路由函数之前返回 422。错误项的核心部分如下:
{
"type": "enum",
"loc": ["body", "level"],
"msg": "Input should be 'beginner', 'intermediate' or 'advanced'",
"input": "expert"
}这里最值得先看的是 loc。["body", "level"] 表示问题在请求体的 level 字段。type 是稳定的错误类别,适合程序判断;msg 更适合开发阶段阅读,但它不是我们自己设计的业务文案。
接口一旦开放,字段名就会成为协议的一部分。客户端可能约定用驼峰命名 courseTitle,Python 代码却更适合使用蛇形命名。别名让两边不必互相迁就。
from pydantic import BaseModel, ConfigDict, Field
class CourseCreate(BaseModel):
model_config = ConfigDict(
extra="forbid",
populate_by_name=True,
)
title: str = Field(
alias="courseTitle",
min_length=3,
max_length=60,
description
客户端可以发送 courseTitle,进入 Python 以后仍然访问 payload.title。populate_by_name=True 还允许我们在直接构造模型时使用内部字段名 title。如果只想给输入和输出使用不同名称,也可以分别使用 validation_alias 与 serialization_alias,把“接收什么名字”和“发出什么名字”拆开。
别名会影响错误路径和接口文档,最好在接口发布前定下来。大面积改别名本质上是在改协议,不只是重构一个 Python 属性。
alias 会同时参与输入和序列化配置,而 validation_alias 只影响输入,serialization_alias 只影响输出。需要兼容旧客户端字段时,先明确是只兼容输入,还是连响应字段也要改变。
课程数据不可能永远只有几个扁平字段。我们还需要讲师资料和课时列表。Pydantic 允许一个模型直接引用另一个模型,列表也能声明元素模型,于是请求体的 JSON 层级会被原样表达出来。
from typing import Annotated
from pydantic import BaseModel, Field
ShortText = Annotated[str, Field(min_length=2, max_length=40)]
class AuthorIn(BaseModel):
name: ShortText
bio: Annotated[str | None, Field(max_length=120)] = None
class

一次合法请求可以写成:
{
"author": {
"name": "林老师",
"bio": "专注后端接口设计"
},
"lessons": [
{
"title": "定义请求模型",
"duration_minutes": 35
},
{
"title": "保护响应边界",
"duration_minutes": 40
}
]
}进入路由函数以后,payload.author 是 AuthorIn,payload.lessons[0] 是 LessonIn。整棵数据树都已经过校验,不需要我们逐层检查字典键是否存在。
嵌套模型还有一个很直接的排错优势。假设第一项课时的标题只有一个字符,同时课时长度只有两分钟,错误会指向:
[
{
"type": "string_too_short",
"loc": ["body", "lessons", 0, "title"],
"msg": "String should have at least 2 characters",
"input": "X"
},
{
"type": "greater_than_equal",
"loc": ["body", "lessons", 0, "duration_minutes"],
"msg"
路径里的 0 就是列表下标。调用方不用猜是哪一项坏了,可以直接把错误定位到 lessons[0].title 和 lessons[0].duration_minutes。列表越长,这种精确定位越有价值。
Field 很适合长度、范围和正则表达式这类声明式约束。遇到“先清理,再校验”的规则时,可以使用 field_validator。课程短地址 slug 就是一个典型例子:我们希望先去掉首尾空格并转为小写,再检查它是否只包含小写字母、数字和连字符。
from typing import Annotated
from pydantic import BaseModel, Field, field_validator
class CourseCreate(BaseModel):
slug: Annotated[
str,
Field(
pattern=r"^[a-z0-9-]+$",
min_length=3,
max_length=50,
),
]
@field_validator
当输入是 " FASTAPI-CONTRACTS " 时,mode="before" 的验证器先接触原始值,把它变成 "fastapi-contracts"。随后 Pydantic 再按字段类型、长度和正则表达式继续检查。
之所以先判断 isinstance(value, str),是因为 before 验证器拿到的是未经类型处理的输入。调用方可能发来整数、列表甚至对象。验证器不应该假设它已经是字符串;不属于自己处理范围的值可以原样返回,让后续类型校验给出标准错误。
mode="after" 则发生在字段已经完成类型校验之后。此时验证器拿到的值类型更可靠,适合处理依赖已解析类型的规则。无论哪种模式,验证器都要返回最终值;只检查不返回,会把字段变成 None,这是初学时很容易漏掉的一步。
单字段都合法,不代表整份数据一定合理。例如每节课都在 5 到 180 分钟之间,但 4 节课各 180 分钟,总时长仍然超过了我们允许的 600 分钟。这个规则需要同时看到整份模型,应该放进 model_validator。
from typing import Self
from pydantic import BaseModel, model_validator
class CourseCreate(BaseModel):
lessons: list[LessonIn]
@model_validator(mode="after")
def check_total_duration(self) -> Self:
total = sum(
lesson.duration_minutes
for lesson in self.lessons
)
if total >

mode="after" 的模型验证器在字段和嵌套模型都成功构造之后运行,所以可以直接访问 self.lessons,其中每一项也已经是 LessonIn。验证通过就返回 self;发现冲突则抛出 ValueError。
当总时长超过限制时,返回的错误位置是整个请求体:
{
"type": "value_error",
"loc": ["body"],
"msg": "Value error, 课程总时长不能超过 600 分钟"
}这是合理的,因为问题不属于某一节课,而是多项合计后的结果。如果业务上希望前端把错误显示在某个具体字段旁,可以在错误归一化层将这类模型错误映射到约定路径,但不要为了获得一个好看的路径,把跨字段逻辑硬塞进某个字段验证器。
验证器适合处理纯数据规则:格式归一化、字段间比较、列表合计和状态组合。需要查数据库、调用远程服务或读取当前用户的规则,不适合藏在 Pydantic 验证器里。那些操作会引入网络等待、事务边界和外部失败,放在服务层或依赖函数里更清楚,也更容易测试。
现在把前面的模型合进一个文件。下面这份代码包含创建接口需要的全部定义,可以直接运行:
from datetime import datetime, timezone
from enum import Enum
from typing import Annotated, Self
from fastapi import FastAPI, status
from pydantic import (
BaseModel,
ConfigDict,
Field,
field_validator,
model_validator,
)
app = FastAPI(title="课程目录接口")
COURSES: dict[int, dict
发送下面的请求体:
{
"courseTitle": "FastAPI 数据契约实战",
"slug": " FASTAPI-CONTRACTS ",
"level": "intermediate",
"price_yuan": 199,
"tags": ["FastAPI", "数据模型"],
"author": {
"name": "林老师",
"bio": "专注后端接口设计"
},
"lessons": [
{
"title": "定义请求模型",
接口返回状态码 201,响应体是:
{
"id": 1,
"title": "FastAPI 数据契约实战",
"slug": "fastapi-contracts",
"level": "intermediate",
"price_yuan": 199,
"tags": ["FastAPI", "数据模型"],
"author": {
"name": "林老师",
"bio": "专注后端接口设计"
},
"lessons": [
{
我们可以从这份响应观察到三件事。外部字段 courseTitle 进入模型后变成了内部字段 title;slug 经过字段验证器完成了去空格和小写转换;内部记录里存在的 password_hash 与 internal_notes 没有出现在响应中。一次请求同时走完了输入转换、业务处理和输出过滤。
创建模型要求客户端给出一份完整数据,更新模型处理的却是补丁。假设用户只想把价格从 199 元改为 129 元,如果继续使用 CourseCreate,客户端就得把标题、难度、作者和课时全部重发一遍。这既浪费,也容易用旧数据覆盖新数据。
我们为 PATCH 单独定义模型:
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, field_validator
class CourseUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
title: Annotated[
str | None,
Field(min_length=3, max_length=60),
] = None
level: CourseLevel |
这里把默认值设为 None,是为了允许字段不出现在请求里。验证器又拒绝显式传入的 null,于是“没传”和“传了空值”被清楚地区分开:没传代表保持原值,传 null 则是无效请求。

路由里最关键的一行是 model_dump(exclude_unset=True):
from fastapi import HTTPException
@app.patch(
"/courses/{course_id}",
response_model=CoursePublic,
)
def update_course(
course_id: int,
payload: CourseUpdate,
) -> dict:
record = COURSES.get(course_id)
if record is None:
raise HTTPException(
status_code=404
发送:
{
"price_yuan": 129
}changes 只会是:
{"price_yuan": 129}返回状态码 200。原来的标题、短地址、难度、标签、作者和课时都保留,只有价格变为 129。
如果漏掉 exclude_unset=True,model_dump() 会把更新模型中所有默认值也倒出来。那些没有出现在请求里的字段会以 None 进入补丁,原记录就可能被成片清空。
exclude_unset=True 排除的是“调用方没有提供的字段”,不是“值等于默认值的字段”。调用方明确发送 false、0、空列表或 null 时,这些字段仍属于已设置字段。接口是否允许这些值,必须由字段类型和验证规则单独决定。
创建路由故意把两个内部字段放进了记录:
record = {
"id": course_id,
**payload.model_dump(),
"created_at": datetime.now(timezone.utc),
"password_hash": "pbkdf2:never-send-this",
"internal_notes": "等待编辑复核",
}而公开响应模型没有声明它们:
class CoursePublic(BaseModel):
id: int
title: str
slug: str
level: CourseLevel
price_yuan: int
tags: list[str]
author: AuthorIn
lessons: list[LessonIn]
created_at: datetimeFastAPI 会用 CoursePublic 校验并序列化路由返回值。多出来的 password_hash 和 internal_notes 被过滤,客户端拿不到它们。

响应模型还有另一面:如果路由少返回了必填字段,或者字段类型不符合模型,FastAPI 会把它当成服务端错误,而不是替服务端返回一份不符合契约的数据。请求校验失败说明调用方发错了,通常返回 422;响应校验失败说明我们的代码没有兑现输出契约,应记录日志并修复服务端。
响应过滤很有用,但不应该成为返回任意内部对象的理由。更稳妥的做法仍然是定义专门的公开模型,只查询或组装需要的字段,并用测试确认敏感字段不会出现。响应模型是最后一道边界,不是让数据库模型直接裸奔的许可证。
对于公开视图、作者视图和管理员视图,优先建立不同的响应模型,例如 CoursePublic、CourseAuthorView 和 CourseAdminView。相比在路由装饰器里维护越来越长的 response_model_exclude 集合,多个命名清楚的模型更容易审查,也会在接口文档中形成准确结构。
如果要从 ORM 对象属性读取字段,可以给响应模型设置:
from pydantic import BaseModel, ConfigDict
class CoursePublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str然后使用 CoursePublic.model_validate(orm_course)。这只改变“从哪里取值”,不会自动决定哪些字段适合公开;公开边界仍然由模型中明确声明的字段决定。
把多个错误放进同一个创建请求:难度写成 expert,价格写成 -1,第一节课标题只有一个字符、时长只有两分钟,再加一个模型没有声明的 unexpected 字段。FastAPI 返回 422,detail 中有五条错误:
{
"detail": [
{
"type": "enum",
"loc": ["body", "level"],
"msg": "Input should be 'beginner', 'intermediate' or 'advanced'",
"input": "expert"
},
{
"type": "greater_than_equal",
"loc": ["body", "price_yuan"],
"msg": "Input should be greater than or equal to 0",
"input"
每一项都可以按同一方式阅读:
type:机器可识别的错误类别,例如枚举错误、范围错误或额外字段。loc:从请求来源一路走到错误字段的路径,数组下标也会保留。msg:默认说明文字,适合开发阶段排查。input:触发错误的原始输入。ctx:某些错误附带的约束参数,例如最小值或枚举候选值。默认结构对调试很友好,但对公开接口不一定是最终形态。前端通常希望有稳定的顶层业务码、统一消息和容易绑定表单的字段路径。我们可以捕获 RequestValidationError,把错误归一化:
from fastapi import Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def request_validation_handler(
request: Request,
exc: RequestValidationError,
) -> JSONResponse:
errors = []
for item in exc.errors():
path = ".".join(
str(part)
for part
上面那份错误请求会得到:
{
"code": "INVALID_REQUEST",
"message": "请求数据未通过校验",
"errors": [
{
"field": "level",
"code": "enum",
"message": "Input should be 'beginner', 'intermediate' or 'advanced'"
},
{
"field": "price_yuan",
"code": "greater_than_equal",
"message": "Input should be greater than or equal to 0"
},
默认错误项可能带有 input,RequestValidationError 本身也能访问整个请求体。开发环境记录这些信息很方便,生产环境却要谨慎。如果请求体里可能有密码、令牌、身份证号或其他敏感数据,不要把原始请求体直接写进日志,也不要原样回显给客户端。
错误处理器还不应把异常对象直接转成字符串返回。内部文件位置、实现细节和未预料到的上下文都可能跟着泄露。更稳妥的方式是只挑选公开契约需要的 field、code 和安全消息;敏感字段在日志层做掩码,未知服务端异常返回统一错误码,并保留内部追踪编号供排查。
不要把校验失败请求的完整正文无条件写入生产日志。错误定位需要字段路径和错误类别,通常不需要保存密码、令牌或整份个人资料。
模型写得越多,越需要按用途组织,而不是按数据库表机械复制。课程接口至少可以分成下面几类:
CourseCreate:创建所需的完整输入,不包含服务端生成的编号和时间。CourseUpdate:允许省略字段,配合 exclude_unset=True 形成补丁。CoursePublic:公开响应,只包含所有访问者都能看到的字段。CourseAdminView:后台响应,可以增加审核状态和内部备注,但仍不应包含密码类字段。AuthorIn、LessonIn:可复用的嵌套结构,让错误路径和接口文档保持清楚。公共字段可以通过继承或可复用类型减少重复,但不要为了复用,破坏模型的用途边界。一个常见做法是抽出不带敏感字段的 CourseBase,再让创建和公开模型在此基础上增加各自字段。密码、令牌、内部备注等字段则留在明确的输入模型或后台模型中,避免它们顺着公共基类扩散到不该出现的响应。
模型命名也应直接说明方向。Create、Update、Public、AdminView 比一个包揽全部场景的 CourseModel 更容易读。看到函数签名时,开发者就能知道数据正在进入系统、形成补丁,还是准备对外输出。
写完模型后,可以按下面的顺序检查一遍:
先发一份合法请求,确认别名、类型转换、字段验证器和模型验证器都产生了预期结果,同时核对成功状态码。
再分别制造缺字段、错类型、越界值、非法枚举、嵌套列表错误和额外字段,检查每条 422 错误的路径能否准确指向输入位置。
对更新接口只发送一个字段,确认其余字段保持不变;再测试显式 null、0、false 和空列表,确保它们按契约被接受或拒绝。
在路由返回值中故意加入内部备注和敏感字段,确认响应模型会过滤它们;再删掉一个公开必填字段,确认测试能发现响应契约被破坏。
当这些检查都过关以后,Pydantic 模型就不只是“帮我们少写几个 if”。它已经把请求能带什么、数据怎样转换、错误如何定位、更新改动哪些字段、响应允许公开什么,变成了一套会被 FastAPI 持续执行的数据契约。
最后检查接口文档中的字段名、必填项、枚举候选值和范围是否与代码一致,并确认错误响应不会回显敏感请求数据。