第 7 章:参数进阶
第 3 章讲了路径与查询参数,第 4 章讲了请求体。本章补齐 FastAPI 全部 7 种参数来源,并解决一个工程问题:参数多起来后,怎么声明才不会被默认值和
Annotated绕晕。
学习目标
- 掌握 FastAPI 的 7 种参数来源:
Path/Query/Body/Header/Cookie/Form/File - 理解 FastAPI 判定参数来源的规则,能预测一个参数落到哪里
- 能用
Annotated写出可复用、默认值语义清晰的参数声明 - 掌握各参数类的高频校验与元数据选项,并知道哪些场景不能用
- 能独立完成一个同时使用 Path、Query、Header、Cookie 的端点
7.1 七种参数来源总览
| 参数类 | 来源 | 典型用途 | 额外依赖 |
|---|---|---|---|
Path | URL 路径模板 {id} | 资源标识 | 无 |
Query | URL ?key=value | 过滤、分页、排序 | 无 |
Body | 请求体 JSON | 结构化数据(Pydantic 模型) | 无 |
Header | 请求头 | 认证、追踪、内容协商 | 无 |
Cookie | Cookie | 会话、偏好设置 | 无 |
Form | 表单编码 / multipart/form-data | HTML 表单提交 | python-multipart |
File | multipart/form-data | 文件上传 | python-multipart |
7.1.1 参数来源判定流程
只写 q: str | None = None 时,FastAPI 按下面的流程推断来源:
要点:显式标注优先级最高,能覆盖默认推断;但一旦标注了参数类,参数名就再也不会因为出现在路径模板里而被识别为 Path,必须显式写 Path()。
7.2 两种声明写法
| 写法 | 示例 | 问题 |
|---|---|---|
| ❌ 旧式 | q: str | None = Query(None, max_length=50) | 默认值藏在 Query() 里,签名看不出 q 可选;无法抽成类型别名 |
| ✅ 推荐 | q: Annotated[str | None, Query(max_length=50)] = None | 约束与默认值分离,清晰可复用 |
Annotated[str | None, Query(max_length=50)] 只描述约束与元数据,= None 描述默认值,两件事分开,语义立刻清晰。
🔑 官方推荐
Annotated的理由:一是默认值不再藏在参数类调用里;二是Annotated[...]可以抽成类型别名在多处复用,而带默认值的旧写法无法复用。
from typing import Annotated
from fastapi import Query
# 可复用类型别名:定义一次,处处复用
PageSize = Annotated[int, Query(ge=1, le=100, description="每页条数")]
PageNumber = Annotated[int, Query(ge=1, description="页码,从 1 开始")]
@app.get("/articles/")
async def list_articles(page: PageNumber = 1, size: PageSize = 20) -> dict[str, int]:
return {"page": page, "size": size}7.3 Path():路径参数约束
路径参数天然必填,Path(...) 中的 ...(Ellipsis)表示必填。
from typing import Annotated
from fastapi import Path
@app.get("/items/{item_id}")
async def read_item(
item_id: Annotated[int, Path(title="物品 ID", description="物品的数字标识", ge=1, lt=100000)],
) -> dict[str, int]:
return {"item_id": item_id}ge=1 的实际效果:/items/42 返回 200;/items/0 返回 422(Input should be greater than or equal to 1);/items/abc 返回 422(int_parsing);/items/100000 返回 422(触发 lt)。
常用数值约束:gt / ge / lt / le,由 Pydantic 执行,错误结构与其他校验错误完全一致。声明为 Path() 的参数名必须出现在路径模板中;反之,路径模板里的参数也别用 Query() 标注,否则请求直接 422。
7.4 Query():完整选项
q: Annotated[
str | None,
Query(min_length=3, max_length=50, pattern=r"^[a-zA-Z0-9\- ]+$", alias="item-query",
deprecated=True, description="检索关键词,仅允许字母、数字、空格与连字符",
examples=["fastapi tutorial"]),
] = None| 选项 | 作用 |
|---|---|
min_length / max_length | 字符串长度约束 |
pattern | 正则约束(字符串须完全匹配该模式) |
alias | 对外参数名与 Python 形参名不同,用于 item-query 这类带连字符的名字 |
deprecated=True | 在 Swagger UI 中标记为已废弃 |
title / description / examples | 文档元数据与示例值 |
include_in_schema=False | 该参数不出现在文档中 |
# 请求 /legacy/?item-query=fastapi
item_query: Annotated[str | None, Query(alias="item-query")] = None💡 对外参数名统一用
kebab-case或统一用snake_case,选定后靠alias固定,并写进接口文档。
7.5 Body():单值与 embed
单值请求体的约束直接写在 Body() 上:
# 请求体是裸字符串 "fastapi"
name: Annotated[str, Body(min_length=3, max_length=30)] = ...
# 加上 embed=True 后请求体变成对象 {"name": "fastapi"}
name_embedded: Annotated[str, Body(embed=True, min_length=3)] = ...embed=True 的真正用途:当你有多个 Body() 参数时,FastAPI 需要给每个参数一个键名来区分(多参数时默认就会 embed);显式写 embed=True 是为了在只有一个参数时也保持对象结构,避免客户端因参数增减而改请求体形状。
class Item(BaseModel):
name: str
price: float
@app.put("/items/{item_id}")
async def update_item(item_id: int, item: Item, importance: int = Body(gt=0)) -> dict[str, object]:
# 请求体:{"item": {"name": "...", "price": 1.0}, "importance": 5}
return {"item_id": item_id, "item": item, "importance": importance}7.6 Header() 与 Cookie()
@app.get("/context/")
async def read_context(
user_agent: Annotated[str | None, Header()] = None, # 匹配 User-Agent
x_token: Annotated[str | None, Header(convert_underscores=False)] = None, # 匹配 X_Token
session_id: Annotated[str | None, Cookie()] = None, # Cookie 不做下划线转换
) -> dict[str, str | None]:
return {"ua": user_agent, "token": x_token, "sid": session_id}Header 的三个关键行为:
convert_underscores默认为True:形参名里的下划线会转成连字符去匹配,所以user_agent对应请求头User-Agent。- 大小写不敏感:HTTP 请求头名本身不区分大小写(RFC 9110),Starlette 内部用不区分大小写的字典存储,
User-Agent/user-agent/USER-AGENT都能被user_agent接住;但 Python 形参名是大小写敏感的。 Cookie不做下划线转换,因为 Cookie 名区分大小写且常有连字符。
⚠️ 形参名不能以下划线开头(如
_user_agent),Pydantic 会把_name当私有属性而报错。
第 5 章用 response.set_cookie(...) 写入 Cookie,这里的 Cookie() 就是读取端,两端合起来形成闭环。
7.7 Form():表单参数
Form 与 File 都依赖 python-multipart 解析表单编码与 multipart/form-data:
pip install python-multipart
# 或使用 uv
uv add python-multipart未安装时导入或首次请求会报 RuntimeError: Form data requires "python-multipart" to be installed.。
7.7.1 Form 不能与 JSON body 混用
❌ 不推荐:
@app.post("/broken/")
async def broken(item: Item, username: str = Form()) -> dict[str, object]:
# ❌ 启动即报错:
# AssertionError: Cannot specify both Form and Body parameters in the same endpoint.
return {"item": item, "username": username}原因是两者的 Content-Type 互斥:JSON body 要求 application/json,Form 要求表单编码或 multipart/form-data。一个请求只能有一种 Content-Type,FastAPI 无法同时满足。
✅ 推荐——要表单就全用表单字段,要 JSON 就全用 Pydantic 模型:
# 方案 A:全表单,最后组装成 Pydantic 模型做业务校验
@app.post("/form-only/")
async def form_only(
username: Annotated[str, Form(min_length=3, max_length=20)],
email: Annotated[EmailStr, Form()],
age: Annotated[int, Form(ge=0, le=150)],
) -> UserCreate:
return UserCreate(username=username, email=email, age=age)
# 方案 B:全 JSON(推荐,见第 4 章)
@app.post("/json-only/")
async def json_only(user: UserCreate) -> UserCreate:
return user🔑 判断标准:接口消费者是浏览器原生
<form>还是前端fetch?前者用Form,后者用 JSON body。不要为「兼容」而在一个端点上同时支持两种。
7.8 File() 与 UploadFile
| 维度 | bytes + File() | UploadFile |
|---|---|---|
| 底层 | 整个文件读进内存 | SpooledTemporaryFile(超阈值落盘),无内存爆炸风险 |
| 读取 | 直接就是 bytes | await file.read() / file.file |
| 元数据 | 无 | filename / content_type / size |
| 适用 | 小文件(头像、证书) | 任意大小,用户上传 |
@app.post("/documents/")
async def upload_document(
file: Annotated[UploadFile, File(description="待解析的文档")],
) -> dict[str, str | int]:
content = await file.read()
return {"filename": file.filename or "", "type": file.content_type or "", "size": len(content)}💡
bytes版本只需把类型换成Annotated[bytes, File(max_length=1024 * 512)],适合 512KB 以内的小文件。文件类型白名单、大小上限与分片上传详见第 14 章。
7.9 用 Depends 复用整组参数
单参数别名之外,更常用的是把一组分页参数封装成可复用依赖:
class PageParams(BaseModel):
page: int
size: int
def pagination(
page: Annotated[int, Query(ge=1, description="页码,从 1 开始")] = 1,
size: Annotated[int, Query(ge=1, le=100, description="每页条数")] = 20,
) -> PageParams:
return PageParams(page=page, size=size)
PageDep = Annotated[PageParams, Depends(pagination)]
@app.get("/articles/")
async def list_articles(pager: PageDep) -> dict[str, int]:
return {"page": pager.page, "size": pager.size}分页逻辑只写一次,所有列表端点复用,Swagger UI 里也会自动出现这两个参数。
7.10 综合示例:/search 同时用四种来源
@app.get("/api/{tenant}/search")
async def search_in_tenant(
tenant: Annotated[str, Path(min_length=2, max_length=30, pattern=r"^[a-z0-9\-]+$")],
q: Annotated[str, Query(min_length=1, max_length=100, description="检索关键词")],
page: Annotated[int, Query(ge=1)] = 1,
size: Annotated[int, Query(ge=1, le=50)] = 10,
trace_id: Annotated[str | None, Header(alias="X-Trace-Id")] = None,
session_id: Annotated[str | None, Cookie()] = None,
) -> dict[str, object]:
return {"tenant": tenant, "q": q, "trace_id": trace_id, "logged_in": session_id is not None}对应关系:tenant 在路径模板且被 Path() 标注 → 路径参数;q / page / size 是标量且不在路径中 → 查询参数;trace_id 标注 Header(alias=...) → 请求头;session_id 标注 Cookie() → Cookie。所有校验失败都返回统一的 422 结构,客户端只需处理一种错误格式。请求示例:
curl -s "http://127.0.0.1:8000/api/acme/search?q=fastapi&page=2&size=20" \
-H "X-Trace-Id: 3f9c1a" -b "session_id=abc123"7.10.1 错误示范 vs 正确示范
❌ 不推荐:
@app.get("/bad/{item_id}")
async def bad(
item_id: int = Query(ge=1), # 路径参数却标了 Query
user_agent: str = Header(convert_underscores=True), # 默认值藏在参数类里
item_query: str = Query(None), # 对外名风格不统一
) -> None: ...✅ 推荐:
@app.get("/good/{item_id}")
async def good(
item_id: Annotated[int, Path(ge=1)],
user_agent: Annotated[str | None, Header()] = None,
item_query: Annotated[str | None, Query(alias="item-query")] = None,
) -> None: ...常见坑与排查
| 现象 | 原因 | 解决 |
|---|---|---|
启动抛 AssertionError: Cannot specify both Form and Body parameters | 同一路径操作混用了 Form() 与 Pydantic 模型 / Body() | 二选一:全表单(组装成模型做业务校验)或全 JSON |
RuntimeError: Form data requires "python-multipart" to be installed | 未安装 python-multipart | pip install python-multipart 或 uv add python-multipart |
Header 参数永远取不到值 | 请求头真实名称含下划线,却被默认转成了连字符 | 用 Header(convert_underscores=False),或用 alias= 显式指定 |
Header 形参以下划线开头,启动即报错 | Pydantic 把 _name 当私有属性 | 去掉前导下划线,靠 alias 表达对外名称 |
用了 alias 后返回值字段名对不上 | alias 只影响入参解析,不影响返回值序列化 | 出参别名用 Pydantic 的 Field(serialization_alias=...) |
同一参数同时写 Query(None) 和 = None | 旧写法机械改造成 Annotated 时默认值写了两遍 | Annotated[...] 内不放默认值,默认值只写在 = 后面 |
pattern 报 invalid escape sequence 或匹配不上 | 未用原始字符串;或误以为 pattern 是「包含匹配」 | 写 r"^\d{4}$";pattern 是完全匹配,包含匹配需写 .*关键词.* |
路径参数返回 422 且提示字段名不对 | 参数被 Query() 标注,但名字在路径模板里 | 路径参数一律用 Path() |
File() 上传大文件导致内存暴涨 | bytes 会把整个文件读进内存 | 改用 UploadFile,必要时自行限制大小 |
本章小结
| 要点 | 说明 |
|---|---|
| 七种来源 | Path / Query / Body / Header / Cookie / Form / File |
| 判定规则 | 显式标注 > 路径模板 > Pydantic 模型 > 标量默认 Query |
| 推荐写法 | Annotated[T, Query(...)] = 默认值,默认值与元数据分离 |
Annotated 价值 | 可抽成类型别名复用,如 PageSize = Annotated[int, Query(ge=1, le=100)] |
Path 约束 | gt / ge / lt / le,错误结构与 Pydantic 一致 |
alias | 解决连字符参数名;只影响入参,不影响出参 |
Header | 下划线默认转连字符,大小写不敏感,形参不能以 _ 开头 |
Form / File | 需 python-multipart;Form 与 JSON body 不可混用 |
| 大文件 | 优先 UploadFile(SpooledTemporaryFile),别用 bytes |
| 复用 | 用 Depends 封装成组参数,比单个别名更好用 |
练习题
- 实现
GET /products/{sku},要求sku匹配^[A-Z]{3}-\d{4}$,并支持?include=price,stock形式的可选查询参数(提示:list[str]+Query())。 - 把 7.9 节的
pagination改造成支持order_by与order(asc/desc)两个额外参数,取值用Literal["asc", "desc"]约束。 - 写一个端点同时接受
UploadFile与两个Form字段(标题、描述),并说明为什么这里能共存而 7.7.1 节的Form+ JSON 不行。 - 手工制造一次
AssertionError: Cannot specify both Form and Body parameters,记录完整报错信息,然后分别用「全表单」与「全 JSON」两种方案修复。
下一章预告
参数声明解决的是「数据怎么进来」,但数据不合法、资源不存在、权限不足时怎么返回,是另一套体系。下一章把散落各处的错误响应收编成统一的异常处理机制。