Skip to content

第三章 数据校验与序列化 —— Pydantic 核心

本章目标:深入掌握 Pydantic 的数据校验能力,学会用 Field 约束、自定义校验器、嵌套模型构建健壮的数据层,理解响应模型的读写分离思想。

预计时长:60 分钟


3.1 Pydantic Model 基础

上一章我们用 Pydantic 定义了请求体,这一章深入了解它的完整能力。

Pydantic 在 FastAPI 中的角色

客户端 JSON  ──→  Pydantic Model  ──→  你的业务逻辑

                    ├── 类型转换("42" → 42)
                    ├── 数据校验(age < 0 → 报错)
                    ├── 序列化  (Model → JSON)
                    └── 文档生成(自动出现在 /docs)

一句话:Pydantic 是 FastAPI 的数据守门员,所有进出的数据都经过它。

字段类型声明

python
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"张三"最基本的类型
整数int42支持自动转换("42"42
浮点数float3.14int 值也会被接受
布尔booltrue支持智能转换(1True
日期date"2025-06-15"ISO 格式字符串自动解析
日期时间datetime"2025-06-15T10:30:00"ISO 格式
列表list[str]["a", "b"]泛型指定元素类型
字典dict[str, int]{"a": 1}泛型指定键值类型
UUIDUUID"550e8400-..."自动校验 UUID 格式

必填字段 vs 可选字段

这是初学者最常混淆的点,用一张表说清楚:

写法含义JSON 中不传该字段时
name: str必填→ 422 校验错误
name: str = "默认值"有默认值,选填→ 使用默认值
name: str | None = None可选,可以为空→ 值为 None
name: str | None必填,但允许传 None→ 422 校验错误(必须显式传值)

字段默认值

python
from pydantic import BaseModel


class UserCreate(BaseModel):
    username: str                         # 必填,无默认值
    email: str                            # 必填,无默认值
    age: int = 18                         # 选填,默认 18
    nickname: str | None = None           # 可选,默认 None
    role: str = "user"                    # 选填,默认 "user"

完整可运行示例:

python
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 会在合理范围内自动转换类型:

python
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 True

3.2 数据校验

类型对了还不够——age: int 能拦住 "abc",但拦不住 -5。我们需要更精细的校验规则。

内置校验:类型、范围、长度

Python 类型注解本身已经提供了基础校验:

python
class BasicCheck(BaseModel):
    name: str           # 必须是字符串
    age: int            # 必须是整数
    price: float        # 必须是数值
    is_vip: bool        # 必须是布尔

但这只是第一道防线。业务规则需要用 Field() 来表达。

Field() 进阶约束

Field() 是 Pydantic 的字段约束工具,在类型之上添加业务规则:

python
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)

数值约束gtgeltle):

参数含义数学符号示例效果
gt大于>Field(gt=0)值必须 > 0
ge大于等于>=Field(ge=0)值必须 >= 0
lt小于<Field(lt=100)值必须 < 100
le小于等于<=Field(le=100)值必须 <= 100

字符串约束min_lengthmax_lengthpattern):

参数含义示例效果
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])

完整可运行示例

python
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}
bash
fastapi dev chapter03.py

/docs 中测试各种非法输入:

输入预期结果
username: "ab"422 — 长度不足 3
username: "张三"422 — 不匹配正则(含中文)
age: 0422 — 必须 >= 1
age: 200422 — 必须 <= 150
phone: "12345"422 — 不匹配手机号格式

自定义校验器:@field_validator

当内置约束无法满足需求时,使用自定义校验器:

python
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 的关键规则:

规则说明
必须加 @classmethodPydantic V2 要求
第一个参数是 cls类方法的标准写法
第二个参数是字段值已经过类型转换后的值
返回值校验通过时返回(可修改后的)值
抛异常raise ValueError("错误信息") 触发 422

校验器还可以修改值——上面的 v.strip() 会自动去掉前后空格,这在实际开发中非常实用。

使用 @model_validator 做跨字段校验

有时候校验逻辑涉及多个字段的关联关系:

python
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 天然支持模型嵌套和各种复合类型。

模型嵌套

python
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 结构:

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 会返回精确的嵌套错误路径:

json
{
    "detail": [
        {
            "loc": ["body", "company", "address", "zipcode"],
            "msg": "String should match pattern '^\\d{6}$'"
        }
    ]
}

List[Model] —— 模型列表

python
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:

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 和其他复合类型

python
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 参数控制返回数据结构

python
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

完整可运行示例

python
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())
bash
fastapi dev chapter03.py

测试流程:

步骤操作关键观察点
1POST /users,body: {"username": "zhangsan", "password": "12345678", "email": "zs@test.com"}返回中没有 password 和 hashed_password
2GET /users/1同样只返回 UserResponse 中定义的字段
3GET /users返回列表,每个元素都是 UserResponse 格式

response_model 的其他实用参数

python
@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() 约束和自定义校验器:

python
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,用本章学到的知识增强数据校验:

  1. TodoCreatetitle 添加 min_length=1, max_length=200 约束
  2. 添加 priority 字段:int = Field(ge=1, le=5, default=3)(1-5 的优先级)
  3. 添加自定义校验器:title 不能全是空格
  4. 将返回结果改用 response_model=TodoResponse 控制输出

参考代码:

python
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]

本章小结

概念要点
字段类型strintfloatbooldatedatetimelistdict 等,支持自动转换
必填 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 文档更专业、更好用。

坚持是一种品格