Skip to content

第 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 最小可运行示例 ​

python
# 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 item
bash
fastapi 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() 约束全解 ​

python
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_lengthstr、list、dict长度区间string_too_short / too_long
patternstr正则,匹配整个字符串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:

json
{
  "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 的嵌套能力是免费的——写好注解就自动递归校验。

python
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 的判定规则。

python
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 把它们按参数名并成一层:

json
{"item": {"name": "键盘", "price": 499}, "user": {"username": "moqian"}}

很多人第一次写多模型接口就在这里踩坑——body 少写一层键,就会收到 loc: ["body", "item"] 的 missing 错误。反过来,只有一个模型参数、但你希望 body 里也带参数名时,用 Body(embed=True):

python
@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 的写法,日常最常用这四项:

python
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_attributesbool允许从对象属性读取数据(v2 取代 orm_mode)ORM 对象转 schema 的关键,见下
str_strip_whitespacebool字符串自动 .strip()用户名、邮箱、标题等用户输入
validate_assignmentboolobj.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 逐个取值:

python
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() 只能描述单个字段的静态约束,跨字段、依赖上下文的规则要靠校验器。

python
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 保留字,直接当字段名会语法错误:

❌ 不推荐:

python
from pydantic import BaseModel


class Message(BaseModel):
    from_: str  # 外部契约要求字段名就叫 from,这里改名导致解析失败

✅ 推荐:

python
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 = Truemodel_config = ConfigDict(from_attributes=True)改名且不再是内部类
allow_population_by_field_name = TrueConfigDict(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 必填)语义变化最大的一条

最后一条要单独强调:

python
from pydantic import BaseModel


class V2Demo(BaseModel):
    a: str | None            # 必填,但值可以是 null
    b: str | None = None     # 可选,不传就是 None

v1 里写 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)
ConfigDictextra="forbid" 防拼写错误,from_attributes=True 是 ORM 转 schema 的开关
校验器字段级用 field_validator,跨字段用 model_validator(mode="after"),抛 ValueError
v1 → v2@validator→@field_validator、Config→model_config、Optional 变必填

练习题 ​

  1. 设计 ArticleCreate:title(3~100 字符)、slug(小写字母数字与连字符)、tags(最多 5 个,每个去空白)、publish_at(可选 datetime)。要求未声明字段一律报错,并写出一个触发三种不同校验错误的请求体与对应的 loc 路径。

  2. 实现 POST /events:同时接收路径参数 calendar_id、查询参数 timezone 和请求体 EventCreate(含 start_at / end_at)。用 model_validator 保证结束晚于开始,timezone 缺失时默认 "Asia/Shanghai",并写出 curl 调用示例。

  3. 下面这段 v1 时代代码迁移到 v2 有 4 处问题,请指出并改正:

python
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
  1. 给 OrderCreate 加 discount_map: dict[str, float],要求所有 value 在 0~1 之间且最多 5 个 key。分别用 Field() 和 field_validator 实现,并说明取舍。

下一章预告 ​

请求数据进来了、校验也稳了,接下来解决另一半问题:返回给客户端的数据该长什么样、如何避免把密码这类字段泄露出去。

👉 第 5 章:响应模型与状态码

📖本文阅读--次|📊全站访问--次|👥访客--人