1. Pydantic
FastAPI 使用 Pydantic 把外部输入转换成符合模型类型和字段约束的 Python 对象。它读取类上的类型提示,完成解析、校验、错误描述和 JSON Schema 生成。
1from pydantic import BaseModel23class ChatRequest(BaseModel):4question: str5temperature: float = 0.76stream: bool = False
这不是一个普通的数据字典,而是一份数据契约:question 必须存在,temperature 应该能被解析为浮点数,stream 应该能被解析为布尔值。
这一章的案例位于 src/fastapi/api/validation/
两个 HTTP 案例先在 validation/__init__.py 中聚合,并统一声明 /validation 前缀:
1from fastapi import APIRouter23from . import fastapi_request, response_model45router = APIRouter(prefix="/validation", tags=["validation"])6router.include_router(fastapi_request.router)7router.include_router(response_model.router)
上层 api/router.py 再将验证模块挂载到带有 /api/v1 前缀的版本路由中:
01from fastapi import APIRouter0203from . import deps, req, validation04from .routes import health0506# 需要版本管理的业务接口统一挂载在 /api/v1 下。07api_v1_router = APIRouter(prefix="/api/v1")08api_v1_router.include_router(health.router)09api_v1_router.include_router(deps.router)10api_v1_router.include_router(req.router)11api_v1_router.include_router(validation.router)1213# 根路由负责聚合不同版本的 API,供应用入口一次性接入。14router = APIRouter()15router.include_router(api_v1_router)
这样,fastapi_request.py 中定义的相对路径 /chat,最终会组合成 /api/v1/validation/chat。
FastAPI 把它用作请求模型时,会在调用路径函数之前完成校验。只有校验通过,路径函数才会收到 ChatRequest 实例。
下面的案例会向本地 FastAPI 服务发送 POST /api/v1/validation/chat。你可以修改问题、温度和 stream,观察请求模型如何接收并返回解析后的字段:
01from fastapi import APIRouter02from pydantic import BaseModel0304router = APIRouter()0506class ChatRequest(BaseModel):07question: str08temperature: float = 0.709stream: bool = False1011@router.post("/chat")12async def chat(request: ChatRequest) -> dict[str, str | float | bool]:13return {14"question": request.question,15"temperature": request.temperature,16"stream": request.stream,17}
客户端发送一个 JSON 对象:
1{2"question": "介绍一下 FastAPI",3"temperature": "0.5",4"stream": false5}
请求进入路径函数时,request.temperature 已经是 float 类型。如果请求缺少 question,或者 temperature 无法转换成数字,FastAPI 会在函数执行前返回校验错误。
2. 解析
日常常把 Pydantic 的行为统称为「校验」,但它默认不只是判断对错,还会尝试把输入解析成目标类型。例如字符串 "20" 可以转换成整数 20。因此更准确的过程是:读取外部数据、尝试转换、执行约束,再得到模型实例。
这也解释了为什么类型提示不是注释。Pydantic 会在运行时读取它们,生成字段规则。转换成功不代表业务一定合理,例如 temperature=1.5 是否允许,仍要通过 Field() 或业务规则明确声明。
3. 实例
Pydantic 模型也可以在 FastAPI 之外直接使用:
1request = ChatRequest(2question="介绍一下 FastAPI",3temperature=0.5,4)56print(request.question)7print(request.temperature)8print(request.model_dump())
model_dump() 返回普通 Python 字典:
1{2"question": "介绍一下 FastAPI",3"temperature": 0.5,4"stream": False,5}
Pydantic v2 使用 model_dump(),旧代码中的 .dict() 是 v1 时代的常见写法。阅读旧资料时要注意版本差异。
外部字典或 JSON 字符串通常使用下面两个入口:
01from pydantic import BaseModel0203class ChatRequest(BaseModel):04question: str0506from_dict = ChatRequest.model_validate({"question": "你好"})07from_json = ChatRequest.model_validate_json('{"question":"你好"}')0809print(from_dict.model_dump())10print(from_json.model_dump())
model_validate() 接收 Python 对象,model_validate_json() 直接解析 JSON 字符串。对于查询参数、表单等“键和值都是字符串”的数据,还可以使用 model_validate_strings():
1from pydantic import BaseModel23class Pagination(BaseModel):4limit: int56page = Pagination.model_validate_strings({"limit": "20"})7print(page.limit) # 20
model_dump() 返回 Python 数据,model_dump_json() 返回 JSON 字符串。包含 datetime、UUID 等字段时,如果需要得到可以直接交给 JSON 编码器的数据,可以使用 model_dump(mode="json")。名称里的 validate 和 dump 分别对应「进入模型」与「离开模型」。
模型还可以生成 JSON Schema:
1print(ChatRequest.model_json_schema())
FastAPI 会把这些模型信息用于 OpenAPI 文档,因此字段类型和 Field() 约束不仅影响运行时校验,也会影响接口文档。
在 FastAPI 路径函数里通常不必手动调用这些方法,框架已经替你完成请求解析;它们更常用于配置、消息队列、缓存或测试。
如果只需要校验一个列表、字典或普通类型,而不需要创建完整的模型,可以使用 TypeAdapter:
1from pydantic import TypeAdapter23numbers = TypeAdapter(list[int]).validate_python(["1", 2])4print(numbers) # [1, 2]
TypeAdapter 适合复用 Pydantic 的转换和校验能力,但它不会替你定义一个带字段名的 JSON 对象。
4. 必填
是否必填由「有没有默认值」决定,而不只由类型里有没有 None 决定:
1from pydantic import BaseModel23class Example(BaseModel):4title: str5description: str | None6note: str | None = None
三个字段的含义不同:
| 字段 | 是否必填 | 是否允许 None |
|---|---|---|
title | 是 | 否 |
description | 是 | 是 |
note | 否 | 是 |
description: str | None 表示调用方必须传这个字段,但值可以为 null。只有提供默认值 = None 后,它才成为可省略字段。这是 Pydantic v2 中很容易混淆的地方。
默认值通常代表“没有传入时使用什么”,而不是“这个默认值也要重新校验”。如果默认值来自配置文件、环境变量或其他不可信来源,应显式开启默认值校验:
1from pydantic import BaseModel, ConfigDict, Field23class Settings(BaseModel):4model_config = ConfigDict(validate_default=True)56limit: int = Field(default="20")
对于代码中已经确定的字面量默认值,例如 stream: bool = False,通常不需要额外开启这个选项。
5. Field
Field() 可以声明数值、长度和文档约束:
01from typing import Annotated0203from pydantic import BaseModel, Field0405class ChatRequest(BaseModel):06question: Annotated[07str,08Field(min_length=1, max_length=4000),09]10temperature: Annotated[11float,12Field(ge=0, le=2),13] = 0.714max_tokens: Annotated[15int,16Field(gt=0, le=8192),17] = 1024
这些限制会同时进入请求校验和 OpenAPI 文档。边界应该来自真实业务或供应商限制,不要随意写一个看似安全的数字。
常用类型
除了 str、int、float 和 bool,Pydantic 还提供了一些常见的业务类型:
01from datetime import datetime02from decimal import Decimal03from uuid import UUID0405from pydantic import BaseModel, EmailStr, HttpUrl0607class UserProfile(BaseModel):08id: UUID09email: EmailStr10website: HttpUrl | None = None11balance: Decimal12registered_at: datetime
UUID、datetime 和 Decimal 会按对应格式解析;EmailStr 和 HttpUrl 会执行更具体的格式检查。使用 EmailStr 需要安装额外依赖:uv add "pydantic[email]"。类型越贴近业务,错误越早暴露,生成的接口文档也越准确。
6. 嵌套
真实请求通常包含嵌套对象和列表:
01from typing import Literal0203from pydantic import BaseModel, Field0405class Message(BaseModel):06role: Literal["user", "assistant"]07content: str = Field(min_length=1)0809class ChatRequest(BaseModel):10messages: list[Message] = Field(min_length=1)11conversation_id: str | None = None
发送 JSON 后,request.messages 是 Message 对象列表。可以直接访问 request.messages[0].content,不需要在业务代码里反复检查键是否存在。
Literal 把 role 限定在已知取值中。以后增加 system 或 tool 等角色时,应该根据实际协议扩展,而不是先接受任意字符串再在深层逻辑中失败。
当一个字段可能对应多种明确结构时,可以使用联合类型;如果需要根据某个字段选择具体分支,可以使用判别字段:
01from typing import Annotated, Literal0203from pydantic import BaseModel, Field0405class TextPart(BaseModel):06type: Literal["text"]07text: str0809class ImagePart(BaseModel):10type: Literal["image"]11url: str1213ContentPart = Annotated[14TextPart | ImagePart,15Field(discriminator="type"),16]1718class Message(BaseModel):19content: ContentPart
这里的 type 不只是普通字段,它告诉 Pydantic 应该使用哪个模型分支校验输入。
7. 默认集合
集合字段建议使用 default_factory 表达「每个实例创建一份新集合」:
1from pydantic import BaseModel, Field23class Conversation(BaseModel):4title: str5tags: list[str] = Field(default_factory=list)6metadata: dict[str, str] = Field(default_factory=dict)
Pydantic 会处理模型默认值,但 default_factory 更清楚地表达所有权,也与普通 Python 类中避免共享可变默认值的习惯一致。
8. 转换
Pydantic 默认会进行一部分合理转换:
1from pydantic import BaseModel23class Pagination(BaseModel):4limit: int56page = Pagination.model_validate({"limit": "20"})7print(page.limit) # 20
这对 HTTP 很实用,因为查询参数和表单字段原本都是文本。但转换并不意味着任意数据都会被接受,也不应该依赖模糊转换表达关键业务规则。
需要区分三种常见入口:model_validate() 处理已经解析好的 Python 对象,model_validate_json() 处理 JSON 文本,model_validate_strings() 则把字典中以字符串表示的值按字段类型解析。它们都默认使用宽松模式,但“宽松”不等于无限制。例如整数通常可以从数字字符串转换,却不能从任意单词转换;布尔值也可能接受 "true"、"0" 等约定格式。
需要严格类型时,可以开启严格模式:
1from pydantic import BaseModel, ConfigDict23class StrictPagination(BaseModel):4model_config = ConfigDict(strict=True)56limit: int
严格模式也可以只作用于一次校验或一个字段:
01from typing import Annotated0203from pydantic import BaseModel, Field, ValidationError0405class Request(BaseModel):06limit: Annotated[int, Field(strict=True)]0708try:09Request.model_validate({"limit": "20"}, strict=True)10except ValidationError as error:11print(error.errors())
是否严格应根据数据来源决定。HTTP 查询字符串天然是文本,全面严格可能反而难用;内部事件、配置或金额等敏感数据则适合更严格的契约。严格模式是输入边界的选择,不是“校验级别越高越好”。
9. 额外字段
默认情况下,模型会忽略未声明的额外字段(extra="ignore")。对外部 API,希望尽早发现拼写错误时,可以禁止额外字段:
1from pydantic import BaseModel, ConfigDict23class ChatRequest(BaseModel):4model_config = ConfigDict(extra="forbid")56question: str
如果客户端误传 qusetion,接口会直接报告未知字段,而不是悄悄丢弃它。不过对需要向前兼容的协议,完全禁止额外字段也可能让旧客户端过于脆弱,需要结合版本策略决定。
extra 有三个常用策略:ignore(默认,忽略额外字段)、forbid(遇到额外字段时报错)和 allow(保留额外字段)。如果选择 allow,额外数据会放在模型的 model_extra 中;这适合需要透传扩展字段的协议,但业务代码应该明确哪些字段可以被信任。
模型创建后再次赋值时,默认不会重新校验。配置或编辑场景需要这个能力时,可以开启 validate_assignment:
01from pydantic import BaseModel, ConfigDict, Field, ValidationError0203class Settings(BaseModel):04model_config = ConfigDict(validate_assignment=True)0506limit: int = Field(ge=1)0708settings = Settings(limit=10)0910try:11settings.limit = 012except ValidationError as error:13print(error.errors())
10. 校验器
字段间或内容级规则可以使用校验器:
01from pydantic import BaseModel, field_validator0203class ChatRequest(BaseModel):04question: str0506@field_validator("question")07@classmethod08def validate_question(cls, value: str) -> str:09normalized = value.strip()1011if not normalized:12raise ValueError("问题不能为空")1314return normalized
校验器返回的值会成为字段最终值,因此这里顺便去掉了首尾空白。校验器适合数据自身的规则,不适合执行数据库查询、模型调用或其他昂贵 IO。
如果同一条规则会被多个模型复用,可以把它提取为 Annotated 类型。默认的 AfterValidator 会在字段已经完成类型解析后执行;需要先处理原始输入时,则可以使用 BeforeValidator。
01from typing import Annotated0203from pydantic import AfterValidator, BaseModel0405def non_empty(value: str) -> str:06value = value.strip()07if not value:08raise ValueError("文本不能为空")09return value1011NonEmptyText = Annotated[str, AfterValidator(non_empty)]1213class Message(BaseModel):14content: NonEmptyText
涉及多个字段时,可以使用模型级校验器,但应先判断规则是否真的属于数据结构。有些约束属于业务服务,而不是 Pydantic 模型。
01from typing import Self0203from pydantic import BaseModel, model_validator0405class GenerationOptions(BaseModel):06min_tokens: int = 107max_tokens: int = 10240809@model_validator(mode="after")10def validate_token_range(self) -> Self:11if self.min_tokens > self.max_tokens:12raise ValueError("min_tokens 不能大于 max_tokens")1314return self
字段校验器适合单个字段,模型校验器适合字段之间的关系。mode="after" 表示先完成字段解析,再基于已经生成的模型检查整体规则。mode="before" 可以在解析前清理或改写原始输入,但此时拿到的可能是字典、字符串等不同形态的数据。不要在校验器中修改数据库或调用外部 API,因为校验可能在多个模型边界重复发生,也会让测试和重试变得不可预测。
11. 别名
外部协议的字段名不一定符合 Python 风格。可以用别名把二者分开:
1from pydantic import BaseModel, ConfigDict, Field23class ChatRequest(BaseModel):4model_config = ConfigDict(5validate_by_alias=True,6validate_by_name=True,7)89max_tokens: int = Field(alias="maxTokens", ge=1)
客户端可以发送 maxTokens,代码内部继续使用 request.max_tokens。validate_by_alias=True 允许使用外部别名,validate_by_name=True 允许使用 Python 字段名。两者同时开启,就能兼容两种输入写法;如果只希望协议使用别名,可以关闭 validate_by_name。旧资料中常见的 populate_by_name=True 仍然值得认识,但新代码更适合明确表达这两个方向的验证策略。
导出时是否使用别名要显式决定:request.model_dump(by_alias=True) 会得到 {"maxTokens": ...}。一旦字段名已经发布,别名就是兼容性策略的一部分,不应随意更改。
12. 输入输出
不要让同一个模型同时承担数据库记录、外部请求和公开响应的全部职责。输入和输出经常需要不同字段:
01from datetime import datetime0203from pydantic import BaseModel0405class ConversationCreate(BaseModel):06title: str0708class ConversationRead(BaseModel):09id: str10title: str11created_at: datetime
客户端创建会话时不应该自行指定服务器生成的 ID 和时间。拆开模型能明确谁负责提供每个字段,也能防止内部字段意外进入响应。
在 FastAPI 路径函数上声明 response_model,就可以把这个边界落实到接口:
下面的案例会向本地 FastAPI 服务发送 POST /api/v1/validation/conversations。你可以修改会话标题,观察响应模型如何只返回公开字段,并过滤服务端内部字段:
01from datetime import datetime0203from fastapi import APIRouter04from pydantic import BaseModel0506router = APIRouter()0708class ConversationCreate(BaseModel):09title: str1011class ConversationRead(BaseModel):12id: str13title: str14created_at: datetime1516@router.post("/conversations", response_model=ConversationRead)17async def create_conversation(request: ConversationCreate) -> dict[str, object]:18return {19"id": "conversation-1",20"title": request.title,21"created_at": datetime.now(),22"internal_note": "只供服务端调试",23}
response_model 会校验并过滤路径函数的返回值:internal_note 不会出现在公开响应中,created_at 也会按响应模型的定义序列化。它不是只用于生成文档,而是响应边界的一部分。对于不希望让 FastAPI 从返回值推断响应模型的场景,也可以使用返回类型标注或单独声明 response_model。
13. 错误
当请求不符合模型时,FastAPI 默认返回 422,并给出结构化错误位置。例如假设 temperature 声明为 Field(ge=0),客户端发送 {"temperature": -1},响应可能是:
01{02"detail": [03{04"type": "greater_than_equal",05"loc": ["body", "temperature"],06"msg": "Input should be greater than or equal to 0",07"input": -1,08"ctx": {"ge": 0}09}10]11}
loc 的第一段通常表示请求来源,例如 body、path、query 或 header,后面依次是字段名、数组下标或嵌套路径。前端可以根据它定位表单控件,服务端也可以据此记录可读的错误日志。
如果在 FastAPI 外直接使用 Pydantic,可以通过 ValidationError.errors() 获取相同风格的结构化信息:
1from pydantic import BaseModel, ValidationError23class User(BaseModel):4age: int56try:7User(age="not-a-number")8except ValidationError as error:9print(error.errors())
此时 loc 只包含模型字段,不会有 FastAPI 添加的 body 或 query 前缀。
这类错误属于请求结构校验,通常不需要在每个路径函数中手动捕获。后面的异常文章会讲如何定制统一错误协议,以及如何区分校验错误、业务错误和服务异常。
请求校验失败返回 422;响应模型校验失败则通常表示服务端返回了错误结构,FastAPI 会把它作为服务器错误处理,而不是把内部校验细节原样暴露给客户端。两者虽然都使用 Pydantic,责任方不同。