第 4 章:请求体与 Pydantic 模型
路径参数与查询参数只能传标量,真正承载业务数据的是请求体(Request Body)。本章把「JSON 到 Python 对象」这一段的解析、校验与错误定位讲透,它是后面所有章节的数据地基。
学习目标
- 理解 FastAPI 依据类型注解把
BaseModel子类识别为请求体的规则 - 掌握
Field()常用约束,并能读懂约束失败时返回的 422 响应 - 能独立设计嵌套模型、模型列表与「请求体 + 路径 + 查询」混用的端点
- 掌握
ConfigDict常用配置与@field_validator/@model_validator - 能对照表把 Pydantic v1 旧写法迁移到 v2
📌 本章仍用单文件
main.py演示;第 11 章会把这些模型重构到app/schemas/分层结构里。
4.1 请求体到底是什么
请求体是 HTTP 请求中跟在请求头之后、承载数据的那一段。按 HTTP 语义:
| 方法 | 是否应携带请求体 | 典型用途 |
|---|---|---|
GET | 否 | 读取资源,参数走路径/查询串 |
POST | 是 | 创建资源,提交表单或 JSON |
PUT | 是 | 全量替换资源 |
PATCH | 是 | 局部更新资源 |
DELETE | 通常否 | 删除资源 |
FastAPI 判定「一个参数是不是请求体」的规则很机械:
- 类型是
BaseModel子类(或list[Model]、dict[str, float])→ 请求体 - 类型是标量(
int/str/bool/datetime…)→ 查询参数 - 参数名出现在路径模板
{...}中 → 路径参数 - 用
Body()/Query()/Path()显式标注 → 以标注为准
💡 一句话:模型 → body,标量 → query,路径模板里的 → path。第 7 章会把这条规则展开成完整表格。
4.2 最小可运行示例
# main.py
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
in_stock: bool = True
@app.post("/items")
async def create_item(item: Item) -> Item:
# 走到这里时,item 已经是校验通过的 Item 实例
return itemfastapi dev main.py
curl -X POST http://127.0.0.1:8000/items \
-H "Content-Type: application/json" \
-d '{"name": "机械键盘", "price": 499.0}'FastAPI 在这中间替你做了四件事:
关键点:解析失败也会变成 422(不是 500),得到结构化错误而非堆栈;类型转换宽松但有边界("499.0" 会转成 float,"abc" 会失败);处理函数拿到的是模型实例,可以放心用 item.name。
4.3 Field() 约束全解
from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, Field
# 写法 A:直接赋值(教程正文默认用这种)
class ProductA(BaseModel):
sku: str = Field(min_length=3, max_length=32, pattern=r"^[A-Z0-9-]+$")
name: str = Field(title="商品名", description="面向用户的展示名称")
price: Decimal = Field(gt=0, le=99999)
stock: int = Field(default=0, ge=0)
weight_kg: float = Field(default=0.5, multiple_of=0.1)
tags: list[str] = Field(default_factory=list, max_length=10)
internal_code: str | None = Field(default=None, examples=["A-001"])
# 写法 B:Annotated,可把约束抽成可复用类型别名
Sku = Annotated[str, Field(min_length=3, max_length=32, pattern=r"^[A-Z0-9-]+$")]
class ProductB(BaseModel):
sku: Sku
name: str| 参数 | 适用类型 | 作用 | 失败时的 type |
|---|---|---|---|
min_length / max_length | str、list、dict | 长度区间 | string_too_short / too_long |
pattern | str | 正则,匹配整个字符串 | string_pattern_mismatch |
gt / ge | 数值、Decimal | 大于 / 大于等于 | greater_than / greater_than_equal |
lt / le | 数值、Decimal | 小于 / 小于等于 | less_than / less_than_equal |
multiple_of | 数值 | 必须是该数的整数倍 | multiple_of |
default / default_factory | 任意 | 缺省值;可变类型必须用 default_factory | — |
title / description / examples | 任意 | 写进 OpenAPI 文档,不影响校验 | — |
alias | 任意 | 外部字段名 ≠ Python 字段名,见 4.7 | — |
POST /products 发送 {"sku": "ab", "name": "键盘", "price": 0} 时返回的 422:
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "sku"],
"msg": "String should have at least 3 characters",
"input": "ab",
"ctx": {"min_length": 3}
},
{
"type": "greater_than",
"loc": ["body", "price"],
"msg": "Input should be greater than 0",
"input": 0,
"ctx": {"gt": 0}
}
]
}loc 是从上到下的路径数组:["body", ...] 表示数据来自请求体,后面逐层指向字段。前端做表单高亮时拼成 body.sku 即可。
⚠️
pattern不是「包含匹配」。要允许中间出现任意内容,正则得自己写.*。
4.4 嵌套模型与模型列表
真实接口几乎不会只有扁平字段,Pydantic 的嵌套能力是免费的——写好注解就自动递归校验。
from datetime import datetime
from pydantic import BaseModel, Field
class Image(BaseModel):
url: str = Field(pattern=r"^https?://")
width: int = Field(gt=0)
height: int = Field(gt=0)
class OrderItem(BaseModel):
sku: str
quantity: int = Field(ge=1, le=99)
unit_price: float = Field(gt=0)
class OrderCreate(BaseModel):
cover: Image | None = None # 模型内嵌模型
items: list[OrderItem] = Field(min_length=1) # 模型列表
discount_map: dict[str, float] = Field(default_factory=dict) # 内嵌 dict
region: list[list[float]] = Field(default_factory=list) # 二维列表
created_at: datetime | None = None
# 顶层直接是列表:async def bulk_create(orders: list[OrderCreate]) -> ...嵌套校验失败时 loc 会自动带上索引,例如 ["body", "items", 0, "quantity"] 读作「请求体 items 第 0 个元素的 quantity」。调试时先看 loc 最后一项定位字段,再往前读定位是哪个数组元素。
💡
dict[str, float]的 value 会被校验,但 key 永远按字符串处理。需要固定 key 时用Literal判别式联合,或改成list[{key, value}]。
4.5 请求体 + 路径参数 + 查询参数混用
FastAPI 能在一个处理函数里同时解析三种来源,依据就是 4.1 的判定规则。
from fastapi import Body, FastAPI, Path, Query
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
class User(BaseModel):
username: str
full_name: str | None = None
@app.put("/shops/{shop_id}/items/{item_id}")
async def upsert_item(
shop_id: int, # 路径参数
item_id: int = Path(ge=1), # 路径参数 + 约束
item: Item = Body(...), # 请求体的 item 字段
q: str | None = Query(default=None, max_length=50), # 查询参数
notify: bool = False, # 查询参数
user: User = Body(...), # 请求体的 user 字段
) -> dict[str, object]:
return {"shop_id": shop_id, "item_id": item_id, "item": item, "user": user}多个模型参数时 body 结构会变。上面有两个模型参数,OpenAPI 把它们按参数名并成一层:
{"item": {"name": "键盘", "price": 499}, "user": {"username": "moqian"}}很多人第一次写多模型接口就在这里踩坑——body 少写一层键,就会收到 loc: ["body", "item"] 的 missing 错误。反过来,只有一个模型参数、但你希望 body 里也带参数名时,用 Body(embed=True):
@app.post("/items/embedded")
async def create_embedded(item: Item = Body(embed=True)) -> Item:
return item
# 期望请求体:{"item": {"name": "键盘", "price": 499}}4.6 model_config = ConfigDict(...) 常用项
model_config 是 Pydantic v2 替代 v1 内部类 class Config 的写法,日常最常用这四项:
from pydantic import BaseModel, ConfigDict, Field
class UserCreate(BaseModel):
model_config = ConfigDict(
extra="forbid", # 出现未声明字段直接报错
str_strip_whitespace=True, # 字符串自动去首尾空白
validate_assignment=True, # 赋值时也触发校验
populate_by_name=True, # alias 与字段名两种写法都接受
)
username: str = Field(min_length=3, alias="userName")
nickname: str | None = None| 配置项 | 取值 | 语义 | 何时用 |
|---|---|---|---|
extra | "ignore"(默认)/ "forbid" / "allow" | 未声明字段的处理方式 | 对外 API 用 forbid,让拼写错误立刻暴露 |
from_attributes | bool | 允许从对象属性读取数据(v2 取代 orm_mode) | ORM 对象转 schema 的关键,见下 |
str_strip_whitespace | bool | 字符串自动 .strip() | 用户名、邮箱、标题等用户输入 |
validate_assignment | bool | obj.field = x 时校验 | 领域对象需要「永远合法」时(有性能开销) |
为什么 from_attributes 是衔接第 5 章的钥匙
默认(from_attributes=False)时 UserOut.model_validate(obj) 只接受 dict,传对象会抛 Input should be a valid dictionary or instance of UserOut [type=model_type]。开启后 Pydantic 改用 getattr 逐个取值:
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
username: str
created_at: datetime
# row 是任意带 .id / .username / .created_at 属性的对象(如 SQLAlchemy 模型)
def to_out(row: object) -> UserOut:
return UserOut.model_validate(row)第 5 章会用它把 ORM 对象直接作为接口返回值、又不泄露密码字段;第 10、11 章的分层结构里,它是 repositories 与 schemas 之间的标准转接头。
⚠️
extra="forbid"会让所有未声明字段报错,包括前端多传的埋点字段。回显旧数据的场景要评估兼容性。
4.7 自定义校验与 alias
Field() 只能描述单个字段的静态约束,跨字段、依赖上下文的规则要靠校验器。
from datetime import datetime
from pydantic import BaseModel, Field, field_validator, model_validator
class RegisterForm(BaseModel):
username: str = Field(min_length=3, max_length=20)
password: str = Field(min_length=8)
password_confirm: str
phone: str
@field_validator("username", mode="before")
@classmethod
def normalize_username(cls, v: object) -> object:
# mode="before":在类型转换之前拿到原始输入
if isinstance(v, str):
return v.strip().lower()
return v
@field_validator("phone")
@classmethod
def check_phone(cls, v: str) -> str:
# mode="after"(默认):此时 v 已被校验为 str
if not v.isdigit() or len(v) != 11:
raise ValueError("手机号必须是 11 位数字")
return v
@model_validator(mode="after")
def check_passwords_match(self) -> "RegisterForm":
# self 已是校验通过的实例,可以同时读多个字段
if self.password != self.password_confirm:
raise ValueError("两次输入的密码不一致")
return self
class BookingCreate(BaseModel):
start_at: datetime
end_at: datetime
@model_validator(mode="after")
def check_time_range(self) -> "BookingCreate":
if self.end_at <= self.start_at:
raise ValueError("结束时间必须晚于开始时间")
return self@field_validator("a", "b")可一次覆盖多个字段,函数被逐个调用。mode="before"拿到的可能是任何类型(原始 JSON 值);mode="after"拿到的是已转换并校验过的值。field_validator必须加@classmethod;model_validator(mode="after")是实例方法且必须return self。- 抛
ValueError才会变成 422。跨字段失败时loc是模型根["body"],msg带Value error,前缀。
错误示范 vs 正确示范
外部 API 常传 from / class / import 这类 Python 保留字,直接当字段名会语法错误:
❌ 不推荐:
from pydantic import BaseModel
class Message(BaseModel):
from_: str # 外部契约要求字段名就叫 from,这里改名导致解析失败✅ 推荐:
from pydantic import BaseModel, ConfigDict, Field
class Message(BaseModel):
model_config = ConfigDict(populate_by_name=True)
# Python 侧用合法名 from_,对外契约仍是 from
from_: str = Field(alias="from")
to: str
content: str
msg = Message.model_validate({"from": "a@example.com", "to": "b@example.com", "content": "hi"})
print(msg.from_) # a@example.com
print(msg.model_dump(by_alias=True)) # {'from': ..., 'to': ..., 'content': ...}alias 只影响输入解析;要让输出也用别名,必须显式 model_dump(by_alias=True)(或配 serialization_alias)。
4.8 Pydantic v1 → v2 迁移对照
| v1 写法 | v2 写法 | 备注 |
|---|---|---|
@validator("x") | @field_validator("x") | 必须加 @classmethod |
@root_validator | @model_validator(mode="after") | 参数从 values dict 变成 self |
class Config: orm_mode = True | model_config = ConfigDict(from_attributes=True) | 改名且不再是内部类 |
allow_population_by_field_name = True | ConfigDict(populate_by_name=True) | |
.dict() / .json() | .model_dump() / .model_dump_json() | |
parse_obj(data) | model_validate(data) | |
Item.schema() | Item.model_json_schema() | |
Field(regex="...") | Field(pattern="...") | v2 直接移除 regex |
Field(const=True) | Literal[...] | |
x: str | None(v1 视为可选) | x: str | None(v2 必填) | 语义变化最大的一条 |
最后一条要单独强调:
from pydantic import BaseModel
class V2Demo(BaseModel):
a: str | None # 必填,但值可以是 null
b: str | None = None # 可选,不传就是 Nonev1 里写 Optional[str] 等于「不传也行」;迁移到 v2 后同样的注解会变成必填,缺字段直接 422。批量升级时这是报错最多的地方——新代码统一用 X | None。
常见坑与排查
| 现象 | 原因 | 解决 |
|---|---|---|
字段名用 from / class / import,代码直接语法错误 | 是 Python 保留字,不能做变量名 | 改名为 from_,用 Field(alias="from") 保持对外契约 |
| 前端多传或拼错字段,接口却「成功」了 | extra 默认 "ignore",未知字段被静默丢弃 | 对外模型设 model_config = ConfigDict(extra="forbid") |
嵌套模型报 missing,但请求里明明有值 | 多模型参数会自动 embed,loc 指向的不是你以为的层级 | 读 loc 数组逐层定位;必要时用 Body(embed=True) 统一结构 |
Field 与 Annotated 混写,约束「失效」 | 同一字段两种写法叠加,后者覆盖前者 | 同一字段只用一种写法;可复用的约束抽成 Annotated 别名 |
默认值是 [] / {},多个请求之间数据串味 | 可变默认值在定义时只求值一次 | 一律用 default_factory=list / default_factory=dict |
model_dump() 出来 datetime 还是对象,写 JSON 报错 | 默认是 Python 模式,返回 Python 原生类型 | 需要 JSON 兼容结构时用 model_dump(mode="json") |
model_validator(mode="after") 校验没生效 | 忘记 return self,Pydantic 收到 None | 校验器末尾必须返回 self |
| 传了 8 位密码仍报长度不足 | min_length 按字符数算,前后空格也计入 | 配 str_strip_whitespace=True 或在校验器里 strip() |
本章小结
| 要点 | 说明 |
|---|---|
| 判定规则 | 模型 → body,标量 → query,路径模板变量 → path;Body() 可显式覆盖 |
| 自动流程 | JSON 解析 → 类型转换 → 字段约束 → 字段校验器 → 模型校验器 → 处理函数 |
| 失败即 422 | 解析与校验错误都返回结构化 detail 数组,靠 loc 逐层定位 |
Field() | 约束(长度/数值/正则/倍数)+ 元数据(title/description/examples)+ alias |
| 嵌套 | 模型嵌模型、模型列表、dict[str, X] 与二维列表都被递归校验 |
| 多模型参数 | 每个模型参数成为 body 的一个顶层键;单模型想加一层用 Body(embed=True) |
ConfigDict | extra="forbid" 防拼写错误,from_attributes=True 是 ORM 转 schema 的开关 |
| 校验器 | 字段级用 field_validator,跨字段用 model_validator(mode="after"),抛 ValueError |
| v1 → v2 | @validator→@field_validator、Config→model_config、Optional 变必填 |
练习题
设计
ArticleCreate:title(3~100 字符)、slug(小写字母数字与连字符)、tags(最多 5 个,每个去空白)、publish_at(可选datetime)。要求未声明字段一律报错,并写出一个触发三种不同校验错误的请求体与对应的loc路径。实现
POST /events:同时接收路径参数calendar_id、查询参数timezone和请求体EventCreate(含start_at/end_at)。用model_validator保证结束晚于开始,timezone缺失时默认"Asia/Shanghai",并写出curl调用示例。下面这段 v1 时代代码迁移到 v2 有 4 处问题,请指出并改正:
from typing import Optional
from pydantic import BaseModel, Field, validator
class Profile(BaseModel):
nickname: Optional[str]
homepage: str = Field(regex=r"^https?://")
@validator("nickname")
def check_nickname(cls, v):
return v.strip()
class Config:
orm_mode = True- 给
OrderCreate加discount_map: dict[str, float],要求所有 value 在 0~1 之间且最多 5 个 key。分别用Field()和field_validator实现,并说明取舍。
下一章预告
请求数据进来了、校验也稳了,接下来解决另一半问题:返回给客户端的数据该长什么样、如何避免把密码这类字段泄露出去。