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
14from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Login(BaseModel):
username: str
password: str
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
def mixed(item_id: str, login: Login):
return {"item_id": item_id, "login": login.username}1
2POST /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
16from typing import Annotated
from fastapi import Body, FastAPI
app = FastAPI()
def multi_embed(name: Annotated[str, Body(embed=True)],
age: Annotated[int, Body(embed=True)] = 3):
return {"name": name, "age": age}
def multi_flat(name: str, age: int = 3):
return {"name": name, "age": age}1
2
3
4
5POST /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 得到 422loc: ['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
20from 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
5Decimal("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_typeDecimal输出是字符串而不是数字,这是故意的:避免前端浮点精度丢钱。要数字得自己转。- 日期只认 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
16from pydantic import BaseModel, field_validator
class UserIn(BaseModel):
name: str
tags: list[str] = []
def strip_name(cls, v: str) -> str:
return v.strip()
def lower_tags(cls, v: list[str]) -> list[str]:
return [x.lower() for x in v]1
2POST /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
12from pydantic import BaseModel, field_validator
class Pre(BaseModel):
n: int
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
12from pydantic import BaseModel, model_validator
class Range(BaseModel):
low: int
high: int
def check(self):
if self.low > self.high:
raise ValueError("low 不能大于 high")
return self1
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
7from 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
3extra="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
3ignore(默认) -> {'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
10from 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
13from decimal import Decimal
from pydantic import BaseModel, computed_field
class Order(BaseModel):
price: Decimal
count: int
def total(self) -> Decimal:
return self.price * self.count1
2model_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
9from pydantic import BaseModel, field_serializer
class Ser(BaseModel):
created: str = "2026-11-06T00:00:00"
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
28from typing import Annotated, Optional
from fastapi import FastAPI, File, Form, UploadFile
app = FastAPI()
def form_endpoint(username: Annotated[str, Form()],
tags: Annotated[Optional[list[str]], Form()] = None):
return {"username": username, "tags": tags}
def file_endpoint(file: Annotated[UploadFile, File()]):
return {"filename": file.filename, "content_type": file.content_type,
"size": file.size}
def files_endpoint(files: Annotated[list[UploadFile], File()]):
return {"names": [f.filename for f in files]}
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
4RuntimeError: Form data requires "python-multipart" to be installed.
You can install "python-multipart" with:
pip install python-multipartfastapi[standard]里带了(实测版本 0.0.33),只装pip install fastapi才会撞上这条。 -
UploadFile是异步的,读内容要await:upload_read.py 1
2
3
4
5
6
7
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
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
21from 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
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(...),旧写法告警PydanticDeprecatedSince20allow_mutation = FalseConfigDict(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 deprecatedBaseSettings(在 pydantic 里)已搬走,要 from pydantic_settings import BaseSettingslegacy_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
2PydanticDeprecatedSince20: 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 代码是靠...显式写的,迁移时要逐个确认。
- v1 默认不校验多余字段也不报错,v2 也一样(