第三章 数据校验与序列化 —— Pydantic 核心
本章目标:深入掌握 Pydantic 的数据校验能力,学会用 Field 约束、自定义校验器、嵌套模型构建健壮的数据层,理解响应模型的读写分离思想。
预计时长:60 分钟
3.1 Pydantic Model 基础
上一章我们用 Pydantic 定义了请求体,这一章深入了解它的完整能力。
Pydantic 在 FastAPI 中的角色
客户端 JSON ──→ Pydantic Model ──→ 你的业务逻辑
│
├── 类型转换("42" → 42)
├── 数据校验(age < 0 → 报错)
├── 序列化 (Model → JSON)
└── 文档生成(自动出现在 /docs)一句话:Pydantic 是 FastAPI 的数据守门员,所有进出的数据都经过它。
字段类型声明
from datetime import datetime, date
from uuid import UUID
from pydantic import BaseModel
class User(BaseModel):
name: str
age: int
salary: float
is_active: bool
birthday: date
created_at: datetime
tags: list[str]
metadata: dict[str, str]Pydantic 支持的常用字段类型:
| 类型 | Python 类型 | 示例值 | 说明 |
|---|---|---|---|
| 字符串 | str | "张三" | 最基本的类型 |
| 整数 | int | 42 | 支持自动转换("42" → 42) |
| 浮点数 | float | 3.14 | int 值也会被接受 |
| 布尔 | bool | true | 支持智能转换(1 → True) |
| 日期 | date | "2025-06-15" | ISO 格式字符串自动解析 |
| 日期时间 | datetime | "2025-06-15T10:30:00" | ISO 格式 |
| 列表 | list[str] | ["a", "b"] | 泛型指定元素类型 |
| 字典 | dict[str, int] | {"a": 1} | 泛型指定键值类型 |
| UUID | UUID | "550e8400-..." | 自动校验 UUID 格式 |
必填字段 vs 可选字段
这是初学者最常混淆的点,用一张表说清楚:
| 写法 | 含义 | JSON 中不传该字段时 |
|---|---|---|
name: str | 必填 | → 422 校验错误 |
name: str = "默认值" | 有默认值,选填 | → 使用默认值 |
name: str | None = None | 可选,可以为空 | → 值为 None |
name: str | None | 必填,但允许传 None | → 422 校验错误(必须显式传值) |
字段默认值
from pydantic import BaseModel
class UserCreate(BaseModel):
username: str # 必填,无默认值
email: str # 必填,无默认值
age: int = 18 # 选填,默认 18
nickname: str | None = None # 可选,默认 None
role: str = "user" # 选填,默认 "user"完整可运行示例:
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class UserCreate(BaseModel):
username: str
email: str
age: int = 18
nickname: str | None = None
role: str = "user"
@app.post("/users")
def create_user(user: UserCreate):
return user测试不同的请求体:
✅ 最精简(只传必填字段):
{"username": "zhangsan", "email": "zs@example.com"}
→ age=18, nickname=None, role="user"
✅ 全部传:
{"username": "zhangsan", "email": "zs@example.com", "age": 25, "nickname": "小张", "role": "admin"}
❌ 缺少必填字段:
{"username": "zhangsan"}
→ 422 错误:email 是必填的自动类型转换
Pydantic 会在合理范围内自动转换类型:
class Demo(BaseModel):
count: int
price: float
active: bool输入: {"count": "42", "price": "3.14", "active": 1}
结果: {"count": 42, "price": 3.14, "active": true}
✅ 字符串 "42" → int 42
✅ 字符串 "3.14" → float 3.14
✅ 整数 1 → bool True3.2 数据校验
类型对了还不够——age: int 能拦住 "abc",但拦不住 -5。我们需要更精细的校验规则。
内置校验:类型、范围、长度
Python 类型注解本身已经提供了基础校验:
class BasicCheck(BaseModel):
name: str # 必须是字符串
age: int # 必须是整数
price: float # 必须是数值
is_vip: bool # 必须是布尔但这只是第一道防线。业务规则需要用 Field() 来表达。
Field() 进阶约束
Field() 是 Pydantic 的字段约束工具,在类型之上添加业务规则:
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0, description="价格必须大于 0")
quantity: int = Field(ge=0, le=9999, default=0)
discount: float = Field(ge=0, le=1, default=1.0)数值约束(gt、ge、lt、le):
| 参数 | 含义 | 数学符号 | 示例 | 效果 |
|---|---|---|---|---|
gt | 大于 | > | Field(gt=0) | 值必须 > 0 |
ge | 大于等于 | >= | Field(ge=0) | 值必须 >= 0 |
lt | 小于 | < | Field(lt=100) | 值必须 < 100 |
le | 小于等于 | <= | Field(le=100) | 值必须 <= 100 |
字符串约束(min_length、max_length、pattern):
| 参数 | 含义 | 示例 | 效果 |
|---|---|---|---|
min_length | 最小长度 | Field(min_length=1) | 不能为空字符串 |
max_length | 最大长度 | Field(max_length=50) | 最多 50 个字符 |
pattern | 正则匹配 | Field(pattern=r"^\d{11}$") | 必须是 11 位数字 |
通用参数:
| 参数 | 含义 | 示例 |
|---|---|---|
default | 默认值 | Field(default=0) |
description | 字段描述(出现在 /docs 文档中) | Field(description="用户年龄") |
examples | 示例值(出现在文档中) | Field(examples=[25]) |
完整可运行示例
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class RegisterForm(BaseModel):
username: str = Field(
min_length=3,
max_length=20,
pattern=r"^[a-zA-Z0-9_]+$",
description="用户名,3-20 位,只能包含字母、数字、下划线",
)
password: str = Field(
min_length=8,
max_length=50,
description="密码,至少 8 位",
)
age: int = Field(
ge=1,
le=150,
description="年龄,1-150",
)
email: str = Field(
pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$",
description="邮箱地址",
)
phone: str = Field(
pattern=r"^1[3-9]\d{9}$",
description="手机号,11 位数字",
)
@app.post("/register")
def register(form: RegisterForm):
return {"message": "注册成功", "username": form.username}fastapi dev chapter03.py在 /docs 中测试各种非法输入:
| 输入 | 预期结果 |
|---|---|
username: "ab" | 422 — 长度不足 3 |
username: "张三" | 422 — 不匹配正则(含中文) |
age: 0 | 422 — 必须 >= 1 |
age: 200 | 422 — 必须 <= 150 |
phone: "12345" | 422 — 不匹配手机号格式 |
自定义校验器:@field_validator
当内置约束无法满足需求时,使用自定义校验器:
from pydantic import BaseModel, Field, field_validator
class Order(BaseModel):
product_name: str = Field(min_length=1)
quantity: int = Field(gt=0)
unit_price: float = Field(gt=0)
discount_code: str | None = None
@field_validator("product_name")
@classmethod
def name_must_not_contain_special_chars(cls, v: str) -> str:
if any(c in v for c in "!@#$%^&*"):
raise ValueError("商品名称不能包含特殊字符")
return v.strip()
@field_validator("discount_code")
@classmethod
def validate_discount_code(cls, v: str | None) -> str | None:
if v is not None and not v.startswith("DC-"):
raise ValueError("优惠码必须以 DC- 开头")
return v@field_validator 的关键规则:
| 规则 | 说明 |
|---|---|
必须加 @classmethod | Pydantic V2 要求 |
第一个参数是 cls | 类方法的标准写法 |
| 第二个参数是字段值 | 已经过类型转换后的值 |
| 返回值 | 校验通过时返回(可修改后的)值 |
| 抛异常 | raise ValueError("错误信息") 触发 422 |
校验器还可以修改值——上面的 v.strip() 会自动去掉前后空格,这在实际开发中非常实用。
使用 @model_validator 做跨字段校验
有时候校验逻辑涉及多个字段的关联关系:
from pydantic import BaseModel, Field, model_validator
class DateRange(BaseModel):
start_date: str
end_date: str
min_days: int = Field(default=1, ge=1)
@model_validator(mode="after")
def check_date_range(self):
if self.start_date >= self.end_date:
raise ValueError("开始日期必须早于结束日期")
return self两种校验器对比:
@field_validator → 校验单个字段
输入: 该字段的值
用途: 格式检查、值清洗
@model_validator → 校验整个模型(跨字段)
输入: 整个模型实例(mode="after")
用途: 字段间的关联校验(如结束日期 > 开始日期)3.3 嵌套模型与复杂结构
现实中的数据结构往往不是扁平的,Pydantic 天然支持模型嵌套和各种复合类型。
模型嵌套
from pydantic import BaseModel, Field
class Address(BaseModel):
province: str
city: str
street: str
zipcode: str = Field(pattern=r"^\d{6}$")
class Company(BaseModel):
name: str
address: Address
class Employee(BaseModel):
name: str
age: int = Field(ge=18, le=65)
company: Company
skills: list[str] = []对应的 JSON 结构:
{
"name": "张三",
"age": 28,
"company": {
"name": "某科技公司",
"address": {
"province": "北京",
"city": "北京",
"street": "中关村大街1号",
"zipcode": "100080"
}
},
"skills": ["Python", "FastAPI", "SQL"]
}嵌套模型的校验是递归进行的:
Employee
├── name: str ← 校验 str
├── age: int [18,65] ← 校验 int + 范围
├── company: Company ← 递归校验 ↓
│ ├── name: str
│ └── address: Address ← 继续递归 ↓
│ ├── province: str
│ ├── city: str
│ ├── street: str
│ └── zipcode: r"^\d{6}$" ← 正则校验
└── skills: list[str] ← 校验列表中每个元素是 str如果 zipcode 传了 "ABC",Pydantic 会返回精确的嵌套错误路径:
{
"detail": [
{
"loc": ["body", "company", "address", "zipcode"],
"msg": "String should match pattern '^\\d{6}$'"
}
]
}List[Model] —— 模型列表
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI()
class OrderItem(BaseModel):
product_name: str
quantity: int = Field(gt=0)
unit_price: float = Field(gt=0)
class Order(BaseModel):
customer_name: str
items: list[OrderItem] # 订单项列表
note: str | None = None
@app.post("/orders")
def create_order(order: Order):
total = sum(item.quantity * item.unit_price for item in order.items)
return {
"customer": order.customer_name,
"total": total,
"item_count": len(order.items),
}请求 JSON:
{
"customer_name": "李四",
"items": [
{"product_name": "键盘", "quantity": 1, "unit_price": 299.0},
{"product_name": "鼠标", "quantity": 2, "unit_price": 89.0}
],
"note": "请尽快发货"
}列表中的每个元素都会被独立校验,比如 quantity: 0 会对应到 items[0].quantity 的错误路径。
Dict 和其他复合类型
class Config(BaseModel):
settings: dict[str, str | int | bool] # 灵活的键值对
scores: list[float] # 数字列表
matrix: list[list[int]] # 二维列表
tags: set[str] # 集合(自动去重)常用复合类型速查表:
| 类型声明 | 含义 | 示例 JSON |
|---|---|---|
list[str] | 字符串列表 | ["a", "b"] |
list[Model] | 模型列表 | [{...}, {...}] |
dict[str, int] | 字符串键、整数值的字典 | {"a": 1} |
set[str] | 字符串集合(自动去重) | ["a", "a", "b"] → {"a", "b"} |
tuple[str, int] | 固定长度元组 | ["hello", 42] |
list[list[int]] | 二维整数列表 | [[1,2],[3,4]] |
3.4 响应模型
到目前为止,我们的接口直接返回了字典或模型。在生产环境中,需要更精确地控制返回给客户端的数据结构。
response_model 参数控制返回数据结构
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class UserIn(BaseModel):
username: str
password: str
email: str
class UserOut(BaseModel):
username: str
email: str
@app.post("/users", response_model=UserOut)
def create_user(user: UserIn):
# 即使这里返回了完整的 user(包含 password),
# FastAPI 也只会返回 UserOut 中定义的字段
return user客户端发送: FastAPI 返回:
{ {
"username":"zs", "username": "zs",
"password":"123", "email": "zs@test.com"
"email":"zs@test" }
} ← password 被 response_model 过滤掉了!这是安全的关键:即使你的代码不小心返回了敏感字段,response_model 也会帮你过滤。
区分输入模型 / 输出模型 / 数据库模型(读写分离思想)
这是 FastAPI 项目中最重要的设计模式之一:
客户端请求 服务端处理 客户端响应
│ │ │
UserCreate UserInDB UserResponse
(输入模型) (数据库模型) (输出模型)
│ │ │
┌──────┐ ┌──────────┐ ┌──────────┐
│用户名 │ → │ id │ → │ id │
│密码 │ → │ 用户名 │ → │ 用户名 │
│邮箱 │ → │ 密码哈希 │ │ 邮箱 │
└──────┘ │ 邮箱 │ │ 创建时间 │
│ 创建时间 │ └──────────┘
└──────────┘
← 密码哈希不返回!每种模型各司其职:
| 模型 | 用途 | 包含密码? | 包含 id? |
|---|---|---|---|
UserCreate | 创建用户时的输入 | ✅ 明文密码 | ❌ |
UserUpdate | 更新用户时的输入 | ❌ | ❌ |
UserInDB | 数据库存储 | ✅ 密码哈希 | ✅ |
UserResponse | 返回给客户端 | ❌ | ✅ |
为什么要这么分?
不分模型的后果:
→ 创建时不需要 id,但模型里有 id → 客户端困惑
→ 返回时带上 password → 安全隐患
→ 更新时所有字段必填 → 只改一个字段也要传全部
分了模型的好处:
→ 每个场景只暴露必要的字段
→ 输入校验规则和输出格式各自独立
→ /docs 文档自动展示正确的 Schema完整可运行示例
from datetime import datetime
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
# ---------- 输入模型:客户端创建用户时发送 ----------
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=20)
password: str = Field(min_length=8)
email: str
# ---------- 输入模型:客户端更新用户时发送 ----------
class UserUpdate(BaseModel):
username: str | None = Field(default=None, min_length=3, max_length=20)
email: str | None = None
# ---------- 数据库模型:内部存储用 ----------
class UserInDB(BaseModel):
id: int
username: str
hashed_password: str
email: str
created_at: datetime
# ---------- 输出模型:返回给客户端 ----------
class UserResponse(BaseModel):
id: int
username: str
email: str
created_at: datetime
# ---------- 模拟存储 ----------
fake_db: dict[int, dict] = {}
next_id = 1
def fake_hash_password(password: str) -> str:
return f"hashed_{password}"
# ---------- 接口 ----------
@app.post("/users", response_model=UserResponse, status_code=201)
def create_user(user: UserCreate):
global next_id
now = datetime.now()
db_user = {
"id": next_id,
"username": user.username,
"hashed_password": fake_hash_password(user.password),
"email": user.email,
"created_at": now,
}
fake_db[next_id] = db_user
next_id += 1
return db_user
@app.get("/users/{user_id}", response_model=UserResponse)
def get_user(user_id: int):
if user_id not in fake_db:
raise HTTPException(status_code=404, detail="用户不存在")
return fake_db[user_id]
@app.get("/users", response_model=list[UserResponse])
def list_users():
return list(fake_db.values())fastapi dev chapter03.py测试流程:
| 步骤 | 操作 | 关键观察点 |
|---|---|---|
| 1 | POST /users,body: {"username": "zhangsan", "password": "12345678", "email": "zs@test.com"} | 返回中没有 password 和 hashed_password |
| 2 | GET /users/1 | 同样只返回 UserResponse 中定义的字段 |
| 3 | GET /users | 返回列表,每个元素都是 UserResponse 格式 |
response_model 的其他实用参数
@app.get(
"/users/{user_id}",
response_model=UserResponse,
response_model_exclude_unset=True,
)
def get_user(user_id: int):
return fake_db[user_id]| 参数 | 效果 |
|---|---|
response_model_exclude_unset=True | 未显式赋值的字段不出现在响应中 |
response_model_exclude_none=True | 值为 None 的字段不出现在响应中 |
response_model_include={"id", "name"} | 只返回指定的字段 |
response_model_exclude={"password"} | 排除指定的字段 |
3.5 动手练习
练习 1:商品数据校验
创建一个商品接口,综合使用 Field() 约束和自定义校验器:
from fastapi import FastAPI
from pydantic import BaseModel, Field, field_validator
app = FastAPI()
class ProductCreate(BaseModel):
name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0, le=999999.99)
category: str = Field(pattern=r"^(电子|服装|食品|图书|其他)$")
stock: int = Field(ge=0, default=0)
tags: list[str] = []
@field_validator("tags")
@classmethod
def validate_tags(cls, v: list[str]) -> list[str]:
if len(v) > 5:
raise ValueError("标签最多 5 个")
return [tag.strip().lower() for tag in v]
@app.post("/products")
def create_product(product: ProductCreate):
return {"message": "创建成功", "product": product}在 /docs 中测试以下输入,观察校验行为:
| 测试输入 | 预期 |
|---|---|
{"name": "", "price": 10, "category": "电子"} | 422 — name 长度不足 |
{"name": "手机", "price": -1, "category": "电子"} | 422 — price 必须 > 0 |
{"name": "手机", "price": 999, "category": "汽车"} | 422 — category 不在允许值中 |
{"name": "手机", "price": 999, "category": "电子", "tags": ["a","b","c","d","e","f"]} | 422 — 标签超过 5 个 |
练习 2:改进第二章的 Todo 应用
回到第二章的 todo_app.py,用本章学到的知识增强数据校验:
TodoCreate的title添加min_length=1, max_length=200约束- 添加
priority字段:int = Field(ge=1, le=5, default=3)(1-5 的优先级) - 添加自定义校验器:
title不能全是空格 - 将返回结果改用
response_model=TodoResponse控制输出
参考代码:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, field_validator
app = FastAPI(title="增强版 Todo API")
class TodoCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=500)
priority: int = Field(ge=1, le=5, default=3)
completed: bool = False
@field_validator("title")
@classmethod
def title_must_not_be_blank(cls, v: str) -> str:
if not v.strip():
raise ValueError("标题不能全是空格")
return v.strip()
class TodoResponse(BaseModel):
id: int
title: str
description: str | None
priority: int
completed: bool
todos: dict[int, dict] = {}
next_id = 1
@app.post("/todos", response_model=TodoResponse, status_code=201)
def create_todo(todo: TodoCreate):
global next_id
new_todo = {"id": next_id, **todo.model_dump()}
todos[next_id] = new_todo
next_id += 1
return new_todo
@app.get("/todos", response_model=list[TodoResponse])
def list_todos():
return list(todos.values())
@app.get("/todos/{todo_id}", response_model=TodoResponse)
def get_todo(todo_id: int):
if todo_id not in todos:
raise HTTPException(status_code=404, detail="Todo 不存在")
return todos[todo_id]本章小结
| 概念 | 要点 |
|---|---|
| 字段类型 | str、int、float、bool、date、datetime、list、dict 等,支持自动转换 |
| 必填 vs 可选 | 无默认值 → 必填;= 值 → 有默认值;| None = None → 可选 |
| Field() 数值约束 | gt(大于)、ge(大于等于)、lt(小于)、le(小于等于) |
| Field() 字符串约束 | min_length(最小长度)、max_length(最大长度)、pattern(正则) |
| @field_validator | 自定义单字段校验,可修改值(如 strip),抛 ValueError 触发 422 |
| @model_validator | 跨字段校验,mode="after" 拿到完整模型实例 |
| 嵌套模型 | Model 中嵌套 Model,校验递归执行,错误路径精确定位 |
| 复合类型 | list[Model]、dict[str, int]、set[str] 等 |
| response_model | 控制返回数据结构,自动过滤多余字段(如密码) |
| 读写分离 | 输入模型 / 输出模型 / 数据库模型分开定义,各司其职 |
下一章预告:我们将学习 FastAPI 自动生成的交互式文档(Swagger UI / ReDoc),以及如何通过代码优化文档展示效果——让你的 API 文档更专业、更好用。