文章目录
-
-
- [第一阶段:为什么需要 Pydantic?(背景与痛点)](#第一阶段:为什么需要 Pydantic?(背景与痛点))
-
- [1. 场景引入:裸奔的 Python 数据处理](#1. 场景引入:裸奔的 Python 数据处理)
- [2. Pydantic 的解法:声明式数据校验](#2. Pydantic 的解法:声明式数据校验)
- 第二阶段:核心功能全景图
- 第三阶段:逐功能实战学习
-
- [🔹 1. 基础模型与类型强制转换(Type Coercion)](#🔹 1. 基础模型与类型强制转换(Type Coercion))
- [🔹 2. Field 字段约束](#🔹 2. Field 字段约束)
- [🔹 3. 自定义校验器(Validators)](#🔹 3. 自定义校验器(Validators))
-
- [3.1 字段级校验器 `@field_validator`](#3.1 字段级校验器
@field_validator) - [3.2 模型级校验器 `@model_validator`(跨字段校验)](#3.2 模型级校验器
@model_validator(跨字段校验))
- [3.1 字段级校验器 `@field_validator`](#3.1 字段级校验器
- [🔹 4. 嵌套模型与复杂结构](#🔹 4. 嵌套模型与复杂结构)
- [🔹 5. 序列化与导出](#🔹 5. 序列化与导出)
- [🔹 6. 配置管理(Settings)](#🔹 6. 配置管理(Settings))
- [🔹 7. 高级类型技巧](#🔹 7. 高级类型技巧)
-
- [7.1 Discriminated Union(标签联合)](#7.1 Discriminated Union(标签联合))
- [7.2 泛型模型](#7.2 泛型模型)
-
第一阶段:为什么需要 Pydantic?(背景与痛点)
1. 场景引入:裸奔的 Python 数据处理
假设你正在开发一个用户注册接口,接收如下 JSON 数据:
python
user_data = {
"name": "Alice",
"age": "25", # 注意:前端传来的是字符串
"email": "alice@example.com",
"signup_ts": "2024-06-01T12:00:00",
"tags": "vip,early-bird" # 注意:前端传来的是逗号分隔字符串
}
在没有 Pydantic 时,你需要写多少防御性代码?
python
# 😩 传统做法:手动校验 + 类型转换地狱
def parse_user(data):
# 1. 检查必填字段
for key in ["name", "age", "email"]:
if key not in data:
raise ValueError(f"Missing {key}")
# 2. 类型转换
try:
age = int(data["age"])
except (ValueError, TypeError):
raise ValueError("age must be an integer")
# 3. 业务校验
if age < 0 or age > 150:
raise ValueError("Invalid age range")
# 4. 日期解析
from datetime import datetime
signup_ts = datetime.fromisoformat(data["signup_ts"])
# 5. 列表拆分
tags = [t.strip() for t in data["tags"].split(",")]
return {"name": data["name"], "age": age, "email": data["email"],
"signup_ts": signup_ts, "tags": tags}
痛点总结:
- 类型不安全 :Python 是动态语言,
dict里的值可能是任何类型。 - 校验逻辑分散:转换、校验、业务规则混在一起,难以维护。
- 重复造轮子:每个接口都要写一遍类似的校验代码。
- 错误信息不友好:报错只告诉你 "Invalid age",不告诉你是哪个字段、什么值导致的。
2. Pydantic 的解法:声明式数据校验
Pydantic 的核心哲学是:用类型注解(Type Hints)定义数据结构,校验和转换自动完成。
python
from pydantic import BaseModel, EmailStr, field_validator
from datetime import datetime
class User(BaseModel):
name: str
age: int
email: EmailStr
signup_ts: datetime
tags: list[str]
@field_validator("tags", mode="before")
@classmethod
def split_tags(cls, v):
if isinstance(v, str):
return [t.strip() for t in v.split(",")]
return v
# ✅ 一行搞定:校验 + 转换 + 结构化
user = User(**user_data)
print(user)
print(type(user.age)) # <class 'int'> 自动将 "25" 转为 25
print(type(user.signup_ts)) # <class 'datetime.datetime'>
print(user.tags) # ['vip', 'early-bird']
🧪 动手验证 1 :将上面两段代码分别运行,对比代码量和可读性。尝试把
age改为"abc",观察 Pydantic 抛出的错误信息有多详细。(注:EmailStr 需安装
pip install email-validator,若不想安装可暂时将EmailStr改为str)
第二阶段:核心功能全景图
| 功能模块 | 解决的问题 | 关键词 |
|---|---|---|
| 基础模型 | 定义结构、自动类型强制转换 | BaseModel, Type Coercion |
| 字段约束 | 值范围、长度、正则等细粒度校验 | Field, constr, conint |
| 自定义校验器 | 复杂业务规则、跨字段校验 | @field_validator, @model_validator |
| 嵌套模型 | 处理复杂层级 JSON | 模型组合、list[SubModel] |
| 配置管理 | 环境变量加载、别名映射 | Settings, alias, ConfigDict |
| 序列化/导出 | 转 dict/JSON、排除敏感字段 | model_dump, model_dump_json |
| 高级类型 | Union、Literal、泛型、 discriminated union | Union, Literal, Generic |
第三阶段:逐功能实战学习
🔹 1. 基础模型与类型强制转换(Type Coercion)
Pydantic 不是 严格的类型检查器,而是数据解析器。它会尽力将输入转换为目标类型。
python
from pydantic import BaseModel
class Product(BaseModel):
id: int
price: float
in_stock: bool
# 🧪 测试:安全的自动转换
p = Product(
id="123", # str → int ✅
price="9.99", # str → float ✅
in_stock="yes" # str → bool ✅ ("yes","true","1","on" → True)
)
print(p.model_dump())
# {'id': 123, 'price': 9.99, 'in_stock': True}
⚠️ 注意 :如果你想要严格模式 (禁止所有隐式转换),可以使用
StrictStr,StrictInt或设置model_config = ConfigDict(strict=True)。
python
from pydantic import BaseModel, StrictInt
class StrictProduct(BaseModel):
id: StrictInt
# StrictProduct(id="123") # ❌ ValidationError! 不接受字符串
StrictProduct(id=123) # ✅ 仅接受真正的 int
🧪 动手验证 2 :测试 in_stock 传入 "no", "false", "0", "" 分别得到什么布尔值。
🔹 2. Field 字段约束
当类型正确但值不合法 时,用 Field 添加约束。
python
from pydantic import BaseModel, Field
class CreateUser(BaseModel):
username: str = Field(
min_length=3,
max_length=20,
pattern=r'^[a-zA-Z0-9_]+$', # 正则:仅字母数字下划线
description="用户名"
)
age: int = Field(ge=0, le=150) # >= 0 且 <= 150
score: float = Field(gt=0, lt=100) # > 0 且 < 100
bio: str = Field(default="", max_length=500) # 可选+默认值
# ✅ 合法
u = CreateUser(username="alice_01", age=25, score=88.5)
print(u.username)
# ❌ 非法 - 试试以下每种情况(取消注释逐个测试)
# CreateUser(username="ab", age=25, score=88.5) # 太短
# CreateUser(username="alice@!", age=25, score=88.5) # 非法字符
# CreateUser(username="alice_01", age=-1, score=88.5) # 年龄超范围
🧪 动手验证 3 :创建一个密码字段,要求至少8位、包含数字和字母(提示:用 pattern 正则实现)。
🔹 3. 自定义校验器(Validators)
3.1 字段级校验器 @field_validator
@field_validator 可以理解为:你给某个字段"挂"了一段函数 ,Pydantic 在创建模型时会自动调用它。
它常用来做两件事:
- 清洗/标准化输入:返回一个"处理后的值",这个值才会最终赋给字段(例如去空格、大小写统一)。
- 拦截非法输入 :如果在校验器里
raise ValueError(...),Pydantic 会把它包装成ValidationError,模型不会创建成功。
@classmethod 在这里的意义是:字段校验发生在构造模型的过程中 ,校验函数以"类方法"的签名被调用(第一个参数是 cls),因此不依赖实例 self。
(你也可以把它理解为:还没拿到完整的 Order(...) 实例之前,就需要先把每个字段的值校验/清洗好。)
python
from pydantic import BaseModel, field_validator
class Order(BaseModel):
product_name: str
quantity: int
@field_validator("product_name")
@classmethod
def normalize_name(cls, v: str) -> str:
"""自动清洗商品名"""
return v.strip().title()
@field_validator("quantity")
@classmethod
def check_quantity(cls, v: int) -> int:
if v <= 0:
raise ValueError("数量必须大于0")
return v
o = Order(product_name=" iphone 15 pro ", quantity=2)
print(o.product_name) # "Iphone 15 Pro" ✅ 自动格式化
3.2 模型级校验器 @model_validator(跨字段校验)
当字段之间有依赖关系时使用:
@model_validator 可以理解为:对"整个模型"做一次统一校验,适合写"跨字段规则"(字段之间的一致性约束)。
- 为什么不用
@field_validator:field_validator更偏向"单字段清洗/校验"。跨字段规则如果硬塞到某个字段的 validator 里,会依赖其它字段是否已校验、可读性也更差;用model_validator意图最清晰。 mode="after"是什么意思 :表示在 Pydantic 完成各字段的解析/校验之后再运行;此时实例已创建,所以方法用self,可以同时访问多个字段。- 为什么要
return self:after模式下需要返回校验后的实例(把它当作"创建完成后的最后一道检查/钩子")。
小提示:这里
start_date/end_date是str,比较的是字符串字典序;在YYYY-MM-DD格式下一般没问题,但更稳的写法是用date/datetime类型再比较。
python
from pydantic import BaseModel, model_validator
class DateRange(BaseModel):
start_date: str
end_date: str
@model_validator(mode="after")
def check_dates(self):
"""after 模式:所有字段已校验完毕,可访问 self"""
if self.start_date > self.end_date:
raise ValueError(
f"开始日期({self.start_date})不能晚于结束日期({self.end_date})"
)
return self
# ✅
DateRange(start_date="2024-01-01", end_date="2024-12-31")
# ❌ 取消注释测试报错
# DateRange(start_date="2024-12-31", end_date="2024-01-01")
🧪 动手验证 4 :创建一个 Transfer 模型,包含 from_account, to_account, amount,用 model_validator 确保两个账户不相同且金额 > 0。
🔹 4. 嵌套模型与复杂结构
真实世界的 JSON 都是嵌套的:
python
from pydantic import BaseModel, Field
class Address(BaseModel):
city: str
street: str
zipcode: str = Field(pattern=r'^\d{6}$')
class Company(BaseModel):
name: str
address: Address # 嵌套单个模型
branch_addresses: list[Address] = [] # 嵌套模型列表
data = {
"name": "Acme Corp",
"address": {"city": "Beijing", "street": "Chang'an Ave", "zipcode": "100000"},
"branch_addresses": [
{"city": "Shanghai", "street": "Nanjing Rd", "zipcode": "200000"},
{"city": "Guangzhou", "street": "Beijing Rd", "zipcode": "510000"},
]
}
company = Company(**data)
print(company.address.city) # Beijing
print(company.branch_addresses[1].city) # Guangzhou
🧪 动手验证 5 :给 Company 添加一个 ceo: str | None = None 字段,测试传 None 和不传两种情况。
🔹 5. 序列化与导出
Pydantic V2 使用 model_dump() 系列方法。
model_dump() 和 model_dump_json() 的区别可以先简单记两句话:
model_dump():返回 Pythondict(给程序继续处理用)model_dump_json():返回 JSON 字符串str(给打印/传输/保存用)
因此 model_dump() 里你看到的值仍是 Python 对象(例如 SecretStr(...)、datetime 等),而 model_dump_json() 会把这些值序列化成 JSON 能表示的形式(并且 SecretStr 会以脱敏形式输出)。
python
from pydantic import BaseModel, Field, SecretStr
from datetime import datetime
class UserProfile(BaseModel):
name: str
email: str
password: SecretStr # 敏感字段
created_at: datetime = Field(exclude=True) # 导出时排除
internal_id: int = Field(default=0, exclude=True)
user = UserProfile(
name="Alice",
email="a@b.com",
password="super_secret",
created_at=datetime.now()
)
# 转 dict
print(user.model_dump())
# {'name': 'Alice', 'email': 'a@b.com', 'password': SecretStr('**********')}
# 转 JSON(SecretStr 自动脱敏)
print(user.model_dump_json())
# 按条件排除
print(user.model_dump(exclude={"email"}))
# 只包含指定字段
print(user.model_dump(include={"name", "email"}))
🧪 动手验证 6 :测试 model_dump(mode="json") 与普通 model_dump() 的区别(前者会将 datetime 转为 ISO 字符串)。
🔹 6. 配置管理(Settings)
Pydantic 内置了强大的配置管理,特别适合读取环境变量。
这里我们不再继承 BaseModel,而是使用 pydantic-settings 提供的 BaseSettings:
BaseModel:适合"你手动传入一份数据然后校验/解析"。BaseSettings:适合"应用配置"。你可以直接AppSettings(),它会按规则从环境变量 (以及你配置的.env、CLI 等来源)读取值,然后再进行同样的校验/类型转换。
SettingsConfigDict(...) 就是这个 Settings 模型的"读取规则开关",常用来配置:
env_prefix:环境变量前缀(例如APP_),避免变量名冲突并方便分组case_sensitive:环境变量名是否区分大小写env_file:从.env文件加载(见后面的动手验证)
bash
# 先在终端设置环境变量
export APP_NAME="MyService"
export APP_DEBUG="true"
export APP_DATABASE_URL="postgresql://localhost/mydb"
export APP_REDIS_PORT="6379"
python
from pydantic_settings import BaseSettings, SettingsConfigDict
# ⚠️ 需额外安装: pip install pydantic-settings
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="APP_", # 只读取 APP_ 前缀的环境变量
case_sensitive=False, # 不区分大小写
)
name: str
debug: bool = False
database_url: str
redis_port: int = 6379
settings = AppSettings()
print(settings.name) # MyService
print(settings.debug) # True (自动转换)
print(settings.database_url) # postgresql://localhost/mydb
print(settings.redis_port) # 6379
🧪 动手验证 7 :创建一个 .env 文件写入配置,测试 SettingsConfigDict(env_file=".env") 是否能正确加载。
你可以在项目内创建一个 .env 文件(例如放在 learning/.env),内容类似:
bash
APP_NAME=MyService
APP_DEBUG=true
APP_DATABASE_URL=postgresql://localhost/mydb
APP_REDIS_PORT=6379
然后把 model_config 改成(只展示关键参数):
python
model_config = SettingsConfigDict(env_prefix="APP_", env_file="learning/.env")
这样即使你没有在终端 export ...,也能通过 .env 文件加载配置。
🔹 7. 高级类型技巧
7.1 Discriminated Union(标签联合)
当一个字段可能是"多种不同结构"之一时,可以用 Union[...] 表达;而 Literal 充当"标签字段",让 Pydantic 能根据输入自动选对模型解析。
pet_type: Literal["cat"]:表示pet_type这个字段只能是"cat",否则校验失败pet_type: Literal["dog"]:同理只能是"dog"Pet = Union[Cat, Dog]:表示pet可能是Cat或Dog
当输入里包含 pet_type 时,Pydantic 会据此判断用哪个模型:
{"pet_type": "dog", ...}→ 解析成Dog(...){"pet_type": "cat", ...}→ 解析成Cat(...)
python
from pydantic import BaseModel
from typing import Literal, Union
class Cat(BaseModel):
pet_type: Literal["cat"]
meow_volume: int
class Dog(BaseModel):
pet_type: Literal["dog"]
bark_frequency: float
Pet = Union[Cat, Dog]
class Owner(BaseModel):
name: str
pet: Pet
# Pydantic 根据 pet_type 自动选择正确的模型
owner = Owner(name="Bob", pet={"pet_type": "dog", "bark_frequency": 3.5})
print(type(owner.pet)) # <class 'Dog'> ✅
7.2 泛型模型
泛型(Generic)的核心用途是:复用同一个数据"外壳"结构,但让内部 items 的元素类型可变。
T = TypeVar("T"):声明一个"类型占位符",表示"某一种类型,但先不固定是哪一种"。class PaginatedResponse(..., Generic[T]):把模型声明成"带类型参数"的泛型模型。items: list[T]:表示items里的元素类型由T决定。
当你写 PaginatedResponse[Article](...) 时,相当于把 T 指定为 Article,此时 items 就会被当作 list[Article] 来解析/提示,因此可以安全访问 articles_page.items[0].title。
这种写法非常适合 API 的分页响应:外层结构(total/page/size/items)固定,但 items 可能是 Article、User、Order 等不同模型。
python
from pydantic import BaseModel
from typing import Generic, TypeVar
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
size: int
class Article(BaseModel):
title: str
content: str
# 复用分页结构
articles_page = PaginatedResponse[Article](
items=[Article(title="Hello", content="World")],
total=1, page=1, size=10
)
print(articles_page.items[0].title) # Hello ✅
🧪 动手验证 8 :基于上面的泛型,创建一个 PaginatedResponse[User](使用前面定义的 User 模型),验证类型提示是否正确。