← 返回博客
python2026-09-05 21:18:373 分钟 · 882 2

Pydantic 数据建模:用类型注解把校验写死

FastAPI 的校验能力全部来自 Pydantic v2。从 BaseModel 定义、Field 约束、嵌套与联合类型,到请求模型与响应模型分离、序列化控制,把「数据长什么样、哪些合法」用代码精确锁死。

FastAPI 的「魔法」八成来自 Pydantic。请求进来的 JSON 之所以能自动变成带类型的对象、非法字段自动被挡在门外,全靠它。这篇把 Pydantic v2 的常用能力摊开讲,重点放在和 FastAPI 配合最高频的几块。

最基础的模型

from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str
    email: str
    age: int | None = None

age: int | None = None 表示「可缺省、缺省为 None」。FastAPI 收到请求时,会用这个模型去解析 body:字段名对齐、类型转换、缺字段或类型错就返回 422。

注意 Python 基础类型和 Pydantic 类型不是一回事。age: int 在 Pydantic 里意味着「能转成 int 的都收」(字符串 "18" 会转成 18);如果你要的是「必须是 int 对象」这种语义,Pydantic 也支持严格模式,但默认宽松转换对 API 更友好。

字段约束:Field

光有类型还不够,业务常要「价格大于 0」「名字不超过 50 字」。用 Field 加约束:

from pydantic import BaseModel, Field

class Item(BaseModel):
    name: str = Field(..., min_length=1, max_length=50)
    price: float = Field(..., gt=0, description="单价,必须为正")
    stock: int = Field(default=0, ge=0)

... 是 Pydantic 的「必填」标记,等价于「没有默认值」。常用约束:

  • gt / ge / lt / le:数值大于/大于等于/小于/小于等于
  • min_length / max_length:字符串长度
  • pattern:正则匹配(如邮箱、手机号格式)
  • default:默认值;用 Field(default=...) 也能同时带约束

嵌套、列表与联合

真实数据结构很少是扁平的。Pydantic 支持任意嵌套:

class Address(BaseModel):
    city: str
    street: str

class Order(BaseModel):
    items: list[str]            # 字符串列表
    addresses: list[Address]    # 模型列表,逐项递归校验
    note: str | None = None

# 联合类型:字段可以是多种类型之一
class Id(BaseModel):
    value: int | str

addresses 里的每个元素都会按 Address 递归校验,某一项 city 缺失会精确报出路径 addresses.0.city。这种「深层错误定位」是手写校验很难做到的。

关键配置:ConfigDict

模型类里的 model_config = ConfigDict(...) 控制校验和序列化的行为。两个最常改的:

from pydantic import BaseModel, ConfigDict

class UserOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    name: str
  • from_attributes=True(v1 叫 orm_mode=True):允许从 ORM 对象(如 SQLAlchemy 行)读取属性,而不只是 dict。配合响应模型把数据库行转成 API 输出时必开。
  • extra="ignore":收到多余字段时忽略而不是报错(默认 extra="forbid" 会拒收未知字段)。对接第三方数据时常用 ignore

请求模型与响应模型分离

这是 FastAPI 里一个反直觉但很重要的实践:进来的和出去的用不同模型

class UserCreate(BaseModel):    # 请求:创建用户要提交什么
    name: str
    email: str
    password: str               # 密码只进不出

class UserOut(BaseModel):       # 响应:返回给用户看什么
    id: int
    name: str
    email: str

@app.post("/users/", response_model=UserOut)
async def create_user(data: UserCreate):
    user = db.create(data)      # 伪代码
    return user

response_model=UserOut 做了两件事:一是过滤掉 password 这类敏感字段,即使 handler 返回的对象里有也不会外泄;二是生成响应结构的文档。这让「输入契约」和「输出契约」各自清晰,也天然避免了密码回传的安全坑。

序列化控制

把模型转成 dict / JSON 用这几个方法:

user = UserOut(id=1, name="宁", email="n@x.com")

user.model_dump()                    # → dict
user.model_dump(mode="json")         # → 可 JSON 序列化的 dict(datetime 转字符串)
user.model_dump(exclude={"email"})   # 排除字段

mode="json" 在处理时间、Decimal 这类非原生 JSON 类型时必用,否则直接 json.dumps 会报 TypeError

一个完整例子

把前几篇的点串起来:路由收 UserCreate、用 Field 校验、落库后返回 UserOut、密码不外泄。

from fastapi import FastAPI
from pydantic import BaseModel, Field, ConfigDict

app = FastAPI()

class UserCreate(BaseModel):
    name: str = Field(..., min_length=1, max_length=30)
    email: str = Field(..., pattern=r"^[^@]+@[^@]+\.[^@]+$")
    password: str = Field(..., min_length=6)

class UserOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    name: str
    email: str

@app.post("/users/", response_model=UserOut)
async def create_user(data: UserCreate):
    # 伪代码:hash_password + 入库
    return {"id": 1, "name": data.name, "email": data.email}

访问 /docs 时,文档里会显示请求体要 name/email/password、响应只有 id/name/email,约束条件一并展示。这正是「类型即文档」的落地。

下一篇讲异步数据库——前面这些模型最终要落到存储上。我会用 SQLAlchemy 2.0 的 async 写法,配合本篇的 from_attributes=True 把数据库行直接变响应模型。

相关推荐

本文为原创文章,采用CC BY-NC-SA 4.0协议授权,转载请保留署名与原文链接。原文链接:https://www.wxbuluo.com/article/168