FastAPI-请求体与Pydantic校验

这篇讲什么

  • 请求体这一层是 FastAPI 最能省事的地方:模型即校验,字段约束、嵌套、列表、枚举、日期全都自动做,失败统一返回结构化的 422。
  • 最容易写错的一条:多个标量参数默认被当成查询参数,不是请求体。实测 def multi(name: str, age: int = 3) 收到的是 loc: ['query', 'name'],要进 body 必须 Body(embed=True) 或者干脆用一个模型。
  • 顺带把表单与文件上传放进来(它们也是请求体的一种),并给出 Pydantic v1 → v2 的实测对照表。

请求体的三种声明方式

  • 一个模型参数,就是最标准的 JSON 接口:

    body_model.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    from fastapi import FastAPI
    from pydantic import BaseModel

    app = FastAPI()


    class Login(BaseModel):
    username: str
    password: str


    @app.post("/login")
    def login(form: Login):
    return {"u": form.username}
    1
    POST /login {"username":"u","password":"p"} -> 200 {'u': 'u'}
  • 两个模型混着传也行,一个当 body、另一个当查询参数:

    body_mixed.py
    1
    2
    3
    @app.post("/mixed")
    def mixed(item_id: str, login: Login):
    return {"item_id": item_id, "login": login.username}
    1
    2
    POST /mixed?item_id=X  body={"username":"u","password":"p"}
    -> 200 {'item_id': 'X', 'login': 'u'}

    规则是「复杂类型( BaseModel )走 body,标量走查询串」,所以 item_id: str 必须写在 URL 上。

  • ⚠️ 想「两个标量放 body」必须显式 embed,否则它们悄悄变成查询参数:

    body_embed.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    from typing import Annotated

    from fastapi import Body, FastAPI

    app = FastAPI()


    @app.post("/multi-embed")
    def multi_embed(name: Annotated[str, Body(embed=True)],
    age: Annotated[int, Body(embed=True)] = 3):
    return {"name": name, "age": age}


    @app.post("/multi")
    def multi_flat(name: str, age: int = 3):
    return {"name": name, "age": age}
    1
    2
    3
    4
    5
    POST /multi  body={"name":"n","age":5}
    -> 422 [{'type': 'missing', 'loc': ['query', 'name'], 'msg': 'Field required'}]

    POST /multi-embed body={"name":"n","age":7}
    -> 200 {'name': 'n', 'age': 7}
    • 不报错、不提示,只是「收不到」,这是 FastAPI 新手最常见的静默坑。
    • loc 里的 query 就是线索:你以为在读 body,框架在读 URL。
    • 单个标量想直接当 body(不包一层 key)也可以:payload: Annotated[dict, Body()],实测 {"a": 1} 原样收到 {'payload': {'a': 1}}。
  • 类型联合也一样会被当成查询参数,实测 def union_ep(x: int | str) 传 JSON body 得到 422 loc: ['query','x']——只有 BaseModel(或 Body())才会从请求体取值。

模型与字段

  • 一个覆盖常见需求的模型,字段约束写在 Field 里:

    models.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    from datetime import datetime
    from decimal import Decimal
    from typing import Annotated, Optional

    from pydantic import BaseModel, EmailStr, Field


    class Address(BaseModel):
    city: str
    zip_code: Optional[str] = None


    class UserIn(BaseModel):
    name: Annotated[str, Field(min_length=2, max_length=20, description="姓名")]
    email: EmailStr
    age: Annotated[int, Field(ge=0, le=150)] = 0
    balance: Decimal = Decimal("0")
    joined: Optional[datetime] = None
    tags: list[str] = Field(default_factory=list)
    address: Optional[Address] = None
    • 必填 = 没有默认值;可选 = 有默认值。Optional[str] 只代表「可以为 None」,不会自动变成可选,要写 Optional[str] = None。
    • 可变默认值可以直接写 tags: list[str] = [],实测不报错,而且两个实例不共享同一个列表(a.tags.append('x') 之后 b.tags 仍是 [],a.tags is b.tags 为 False)——Pydantic 会对默认值做深拷贝。这条和 Django/Flask 那边「必须用 factory」的经验不一样,但用 default_factory 仍然更清楚,也更省(不用每次拷贝)。
    • Python 3.10+ 可以写 str | None,实测在模型里和 Optional[str] 等价。
  • 每个约束对应一个固定的错误类型,实测(POST /users):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {}                                              -> 422 missing@body.name; missing@body.email
    {"name":"a","email":"a@b.com"} -> 422 string_too_short@body.name
    {"name":"ab","email":"not-an-email"} -> 422 value_error@body.email
    {"name":"ab","email":"a@b.com","age":200} -> 422 less_than_equal@body.age
    {"name":"ab","email":"a@b.com","balance":"abc"} -> 422 decimal_parsing@body.balance
    {"name":"ab","email":"a@b.com","joined":"not-a-date"}
    -> 422 datetime_from_date_parsing@body.joined
    {"name":"ab","email":"a@b.com","address":{"zip_code":"1"}}
    -> 422 missing@body.address.city
    {"name":"ab","email":"a@b.com","tags":"notalist"}
    -> 422 list_type@body.tags
    {"name":"ab","email":"a@b.com","extra_field":1} -> 200 # 默认多余字段直接忽略
    • 请求体的 loc 一律以 body 开头,嵌套模型会一路点下去(body.address.city)。
    • 默认不拒绝多余字段,想拒绝要在模型上配 model_config = ConfigDict(extra="forbid"),实测报 extra_forbidden@body.zz。
    • 邮箱类错误是 value_error(EmailStr 底层是 email_validator),所以 email-validator 没装的话,导入 EmailStr 就会报错——fastapi[standard] 已经带了。
  • 各种类型的转换行为实测:

    1
    2
    3
    4
    5
    Decimal("12.34")   输入 "12.34" -> 收到 Decimal,输出时序列化成字符串 "12.34"
    datetime 输入 "2026-11-06T21:30:00" -> 200(ISO 8601)
    UUID 输入合法串 -> 200;非法 -> uuid_parsing
    EmailStr 输入 "a@b.com" -> 200;"not-an-email" -> value_error
    list[str] 输入 "notalist" -> list_type
    • Decimal 输出是字符串而不是数字,这是故意的:避免前端浮点精度丢钱。要数字得自己转。
    • 日期只认 ISO 8601,"2026-11-06 21:30:00"(空格分隔)实测也能过,但输出统一是 T 分隔的 ISO 形式。

校验器

  • 字段级校验用 field_validator,必须配 @classmethod:

    validators.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    from pydantic import BaseModel, field_validator


    class UserIn(BaseModel):
    name: str
    tags: list[str] = []

    @field_validator("name")
    @classmethod
    def strip_name(cls, v: str) -> str:
    return v.strip()

    @field_validator("tags")
    @classmethod
    def lower_tags(cls, v: list[str]) -> list[str]:
    return [x.lower() for x in v]
    1
    2
    POST /users {"name":"  李雷  ","email":"a@b.com","tags":["AA","BB"]}
    -> 200 {"name": "李雷", "tags": ["aa", "bb"], "balance": "0", "email": "a@b.com"}

    默认是 mode="after"(拿到已转换的值再处理)。要在校换之前动手就用 mode="before":

    before_validator.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    from pydantic import BaseModel, field_validator


    class Pre(BaseModel):
    n: int

    @field_validator("n", mode="before")
    @classmethod
    def pre(cls, v):
    if isinstance(v, str) and v.isdigit():
    return int(v)
    raise ValueError("只接受数字字符串")
    1
    2
    {"n": "42"} -> 42
    {"n": "abc"} -> ValidationError: value_error, 'Value error, 只接受数字字符串'
  • 跨字段的规则用 model_validator:

    model_validator_demo.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    from pydantic import BaseModel, model_validator


    class Range(BaseModel):
    low: int
    high: int

    @model_validator(mode="after")
    def check(self):
    if self.low > self.high:
    raise ValueError("low 不能大于 high")
    return self
    1
    2
    3
    4
    {"low":1,"high":9} -> 200 {'r': [1, 9]}
    {"low":9,"high":1} -> 422 [{'type': 'value_error', 'loc': ['body'],
    'msg': 'Value error, low 不能大于 high',
    'input': {'low': 9, 'high': 1}, 'ctx': {'error': {}}}]
    • mode="after" 的函数返回 self,签名是实例方法(外面仍套 @classmethod,Pydantic v2 的写法)。
    • 跨字段错误的 loc 就是 ['body'],没有具体字段名,前端要单独处理这类「表单级」错误。
    • 消息里 Value error, 这个前缀是 Pydantic 加的,去掉不了;想干净文案就抛 HTTPException 或在 exception_handler 里改写(见 FastAPI-依赖注入与异常中间件)。

ConfigDict

  • 模型级行为全在 model_config,实测四个最常用的:

    config_demo.py
    1
    2
    3
    4
    5
    6
    7
    from pydantic import BaseModel, ConfigDict


    class Strict(BaseModel):
    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, frozen=True)
    name: str
    note: str = "x"
    1
    2
    3
    extra="forbid"        {"name":"a","zz":1} -> 422 extra_forbidden@body.zz
    str_strip_whitespace {"name":" ab "} -> 200 {'name': 'ab'}
    frozen=True m.name = "cd" -> ValidationError: Instance is frozen [type=frozen_instance]
  • 常用键与用途(本机实测过的标 ✓,其余是官方文档里的键):

    • extra:"ignore"(默认)/ "forbid" / "allow" ✓,实测三者对 {"a":1,"zz":2} 的结果:

      1
      2
      3
      ignore(默认) -> {'a': 1}                 # 多余键丢掉
      allow -> {'a': 1, 'zz': 2} # 多余键留下,能一起导出
      forbid -> 422 extra_forbidden@body.zz
    • frozen:模型变只读,做「值对象」很有用 ✓。

    • str_strip_whitespace:所有字符串自动去首尾空白 ✓。

    • populate_by_name:允许同时用 Python 字段名和别名输入 ✓。

    • strict:关掉隐式类型转换 ✓。

    • from_attributes:允许从 ORM 对象取属性,接数据库时必开,见 FastAPI-数据库。

    • discriminator:给联合类型指定判别字段 ✓(见下面判别联合)。

    • arbitrary_types_allowed:让模型接受自定义类型。

    • validate_default:连默认值也校验。

  • strict=True 的实际差别,实测很直观:

    1
    2
    非 strict: {"n": "123"} -> n = 123 (int)     # 自动转
    strict: {"n": "123"} -> int_type 'Input should be a valid integer'

    对外接口一般保持默认(宽容地帮前端转一下),内部服务之间要精确契约时才开 strict。

别名三件套

  • 三个字段的分工是:alias(输入输出都用)、validation_alias(只管输入)、serialization_alias(只管输出):

    alias_model.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    from typing import Annotated

    from pydantic import BaseModel, ConfigDict, Field


    class Alias(BaseModel):
    model_config = ConfigDict(populate_by_name=True)
    user_name: Annotated[str, Field(alias="userName")]
    city: Annotated[str, Field(validation_alias="cityName",
    serialization_alias="city_name")]
    1
    2
    3
    输入 {"userName":"n","cityName":"SH"}            -> 200 {"userName": "n", "city_name": "SH"}
    输入 {"user_name":"n","cityName":"SH"} -> 200(populate_by_name=True 才允许)
    输出用 model_dump_json(by_alias=True) -> 走 serialization_alias
  • 典型用途就两个:

    • 前端是 camelCase、Python 要 snake_case:模型里写 user_name + alias="userName",两边都舒服。
    • 接口要改名但不能破坏老客户端:新名字放 validation_alias(两个都收),输出继续用老名字。
  • 查询/头/Cookie 参数上的别名是另一套(Query(alias=...)),而且替换后原名不再接受,见 FastAPI-路由与参数。

导出与序列化

  • 模型转字典/JSON 用 model_dump / model_dump_json,实测:

    dump_demo.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    from decimal import Decimal

    from pydantic import BaseModel, computed_field


    class Order(BaseModel):
    price: Decimal
    count: int

    @computed_field
    @property
    def total(self) -> Decimal:
    return self.price * self.count
    1
    2
    model_dump()      -> {'price': Decimal('3.5'), 'count': 2, 'total': Decimal('7.0')}
    model_dump_json() -> {"price":"3.5","count":2,"total":"7.0"}
    • computed_field 标出来的派生值会一起出现在导出里,不用手动加。
    • model_dump(mode="json") 会把 Decimal/datetime 直接转成 JSON 友好的类型,做缓存、写日志时比 default=str 干净。
    • 常用参数:exclude / include / exclude_none / exclude_unset / by_alias,FastAPI 在装饰器上给了一一对应的 response_model_* 版本,见 FastAPI-响应模型与OpenAPI文档。
  • 自定义某字段的输出格式用 field_serializer(v1 的 json_encoders 已经不用了):

    serializer_demo.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    from pydantic import BaseModel, field_serializer


    class Ser(BaseModel):
    created: str = "2026-11-06T00:00:00"

    @field_serializer("created")
    def fmt(self, v: str) -> str:
    return v.replace("T", " ")
    1
    POST /ser {} -> 200 {'created': '2026-11-06 00:00:00'}

表单与文件

  • 表单字段用 Form,文件用 File,两者都要求客户端发 multipart/form-data 或 application/x-www-form-urlencoded:

    form_file.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    from typing import Annotated, Optional

    from fastapi import FastAPI, File, Form, UploadFile

    app = FastAPI()


    @app.post("/form")
    def form_endpoint(username: Annotated[str, Form()],
    tags: Annotated[Optional[list[str]], Form()] = None):
    return {"username": username, "tags": tags}


    @app.post("/file")
    def file_endpoint(file: Annotated[UploadFile, File()]):
    return {"filename": file.filename, "content_type": file.content_type,
    "size": file.size}


    @app.post("/files")
    def files_endpoint(files: Annotated[list[UploadFile], File()]):
    return {"names": [f.filename for f in files]}


    @app.post("/file-and-form")
    def file_and_form(file: Annotated[UploadFile, File()],
    note: Annotated[str, Form()]):
    return {"filename": file.filename, "note": note}
    1
    2
    3
    4
    5
    6
    7
    /form  urlencoded username=lilei&tags=a&tags=b -> 200 {'username': 'lilei', 'tags': ['a', 'b']}
    /form 用 JSON 发 -> 422 missing@body.username
    /file a.txt(text/plain, 13B) -> 200 {'filename': 'a.txt', 'content_type': 'text/plain', 'size': 13}
    /files a.txt + b.txt -> 200 {'names': ['a.txt', 'b.txt']}
    /file-and-form 文件 + note=备注 -> 200 {'filename': 'a.txt', 'note': '备注'}
    /file 字段名给成字符串 "不是文件" -> 422 value_error 'Expected UploadFile, received: <class 'str'>'
    /file 字段名写成 other -> 422 missing@body.file
  • ⚠️ 表单与文件必须装 python-multipart,没装是注册路由那一刻直接抛:

    1
    2
    3
    4
    RuntimeError: Form data requires "python-multipart" to be installed.
    You can install "python-multipart" with:

    pip install python-multipart

    fastapi[standard] 里带了(实测版本 0.0.33),只装 pip install fastapi 才会撞上这条。

  • UploadFile 是异步的,读内容要 await:

    upload_read.py
    1
    2
    3
    4
    5
    6
    7
    @app.post("/upload-read")
    async def upload_read(file: Annotated[UploadFile, File()]):
    content = await file.read()
    await file.seek(0)
    again = await file.read()
    return {"len": len(content), "again_same": content == again,
    "filename": file.filename, "headers_ct": file.headers.get("content-type")}
    1
    -> {'len': 3, 'again_same': True, 'filename': 'x.txt', 'headers_ct': 'text/plain'}
    • 它有 filename、content_type、size、headers;底层对象实测是 SpooledTemporaryFile(5 字节和 2MB 都是这个类型),它按阈值决定留在内存还是滚到磁盘临时文件,所以大文件不至于把内存吃穿——但从类型名上看不出来当前在哪儿,别拿这个当「已经限流」的证据。
    • seek(0) 之后能重读,实测两次内容一致。
    • 只想要 bytes 且不在乎大小,可以用 file: bytes = File(),但别用在大文件上:那会把整个文件读进内存。
  • 大小限制没有现成参数,实测 File() 的签名里没有 max_size(老教程里那种 File(max_size=1024) 在 0.162 上直接 TypeError: File() got an unexpected keyword argument 'max_size')。自己挡有两种:

    size_guard.py
    1
    2
    3
    4
    5
    6
    @app.post("/upload-check")
    async def upload_check(file: Annotated[UploadFile, File()]):
    data = await file.read()
    if len(data) > 10:
    raise HTTPException(status_code=413, detail="文件太大")
    return {"read": len(data), "size_attr": file.size}
    • 上面这种是「读完再拒」,简单但已经付出内存/磁盘代价。
    • 真正要防大流量,得在中间件或 nginx 层按 Content-Length 先拦(见 FastAPI-WebSocket、测试与部署)。
    • 类型白名单不要只信 file.content_type(客户端可以随便填),要读文件头判断,做法与 Flask 那边一致,见 Flask-文件上传。

联合类型与判别联合

  • 普通 Union 会按顺序尝试,容易把数据「塞进错误分支」,所以带一个判别字段是官方推荐做法:

    discriminated.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    from typing import Annotated, Literal, Union

    from fastapi import FastAPI
    from pydantic import BaseModel, ConfigDict, Field

    app = FastAPI()


    class Circle(BaseModel):
    kind: Literal["circle"]
    radius: float


    class Square(BaseModel):
    kind: Literal["square"]
    side: float


    @app.post("/shape")
    def shape(s: Annotated[Union[Circle, Square], Field(discriminator="kind")]):
    return {"kind": s.kind}
    1
    2
    3
    4
    5
    {"kind":"circle","radius":1.5} -> 200 {'kind': 'circle'}
    {"kind":"square","side":2} -> 200 {'kind': 'square'}
    {"kind":"tri","x":1} -> 422 union_tag_invalid
    "Input tag 'tri' found using 'kind' does not match
    any of the expected tags: 'circle', 'square'"
    • Literal 是判别字段的标准写法,模型上也可以直接配 ConfigDict(discriminator="kind")。
    • 判别联合的错误信息会把允许的取值列全,比裸 Union 那种「两个分支各报一遍」好懂得多。
  • 不带 discriminator 的 int | str 实测能正常转换,非法值会同时报两个分支的类型:

    1
    2
    3
    {"a": 5}    -> 通过
    {"a": "x"} -> 通过
    {"a": [1]} -> ['int_type', 'string_type']

v1 到 v2 的对照

  • 网上大量 FastAPI 教程还写着 Pydantic v1 的 API,本机逐条实测:

    v1 写法 v2 里的结果
    @validator 改名 @field_validator,且必须配 @classmethod;旧名还能用,实测照样算出结果,只是打 Pydantic V1 style @validator validators are deprecated
    @root_validator 改名 @model_validator(mode="after")
    class Config: 换成 model_config = ConfigDict(...),旧写法告警 PydanticDeprecatedSince20
    allow_mutation = False ConfigDict(frozen=True)
    json_encoders @field_serializer
    .dict() .model_dump();实测旧名仍返回正确结果,同时打印 PydanticDeprecatedSince20: The dict method is deprecated
    .json() .model_dump_json()
    .parse_obj() .model_validate()
    .copy(update=...) .model_copy(update=...)
    regex= pattern=,实测旧名仍可用但告警 FastAPIDeprecationWarning: regex has been deprecated
    BaseSettings(在 pydantic 里) 已搬走,要 from pydantic_settings import BaseSettings
    legacy_pydantic.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    # 老写法(Pydantic v1),v2 下仍可用但会打 PydanticDeprecatedSince20
    from decimal import Decimal

    from pydantic import BaseModel


    class Order(BaseModel):
    price: Decimal

    class Config:
    extra = "forbid"


    print(Order(price=Decimal("1")).dict())
    1
    2
    PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead.
    PydanticDeprecatedSince20: The `dict` method is deprecated; use `model_dump` instead.
  • 迁移时最容易被咬的两条行为差异:

    • v1 默认不校验多余字段也不报错,v2 也一样(extra="ignore"),但v1 的 Extra.forbid 写在 class Config,改 v2 时忘了搬就会从「严格」变「宽容」。
    • v1 的 Optional[str] 不带默认值时是必填且可为 None,v2 里 Optional 不再隐含默认值,行为一致但很多 v1 代码是靠 ... 显式写的,迁移时要逐个确认。

参考

本文结束 感谢您的阅读
正在加载今日诗词....