FastAPI请求验证:10 个 Pydantic进阶技巧
你的API安全吗?90%的团队都忽略了这一点
我们曾有一个支付接口,接收一个包含price字段的JSON请求。类型提示是float,标准的Pydantic模型。完美运行了好几个月。
直到有一天,一个前端开发同学传了"price": "29.99"——一个字符串,而不是数字。Pydantic居然接受了。它悄无声息地将字符串转换成了29.99,继续执行。没有报错,没有警告。请求照常通过了。
这不是Bug,这是Pydantic的默认行为。它会在背后帮你做类型转换。看起来像数字的字符串就转成数字,"true"变成True,整数1变成浮点1.0。它的设计初衷是“宽容”。
对很多API来说,这没问题。但支付接口不行。悄无声息地接受本该是数字的字符串,就像定时炸弹。我们需要严格校验——也就是从那天起,我意识到大多数Pydantic教程只触及了冰山一角。
下面是过去几年在FastAPI生产项目中,我总结的10个Pydantic V2进阶技巧。如果早点知道,能少踩很多坑。
1. 用严格模式,阻止自动类型转换
默认情况下,Pydantic会想尽办法让数据“合身”。你传整数到字符串字段?它给你转成字符串。你传字符串到浮点数字段?它帮你解析成浮点数。快速原型时很方便,但上生产就危险了。
from pydantic import BaseModel, ConfigDict
# 默认行为——自动类型转换
classPaymentLoose(BaseModel):
amount: float
currency: str
PaymentLoose(amount="29.99", currency=123)
# 成功执行!amount=29.99, currency="123"
# 这可能不是你想要的结果
# 严格模式——拒绝类型不匹配
classPaymentStrict(BaseModel):
model_config = ConfigDict(strict=True)
amount: float
currency: str
PaymentStrict(amount="29.99", currency="USD")
# ValidationError: amount - Input should be a valid number
在模型层面加上ConfigDict(strict=True),Pydantic就会拒绝任何类型不匹配的输入。不转换、不猜测。前端传错类型,直接返回清晰的校验错误。
你也可以只在特定字段上使用严格模式:
from pydantic import BaseModel, StrictInt, StrictStr
classOrder(BaseModel):
quantity: StrictInt # 必须是整数
product_name: StrictStr # 必须是字符串
notes: str | None = None# 这个字段仍然可以转换
⚠️ 注意:建议在涉及金额、数量、敏感数据的模型上开启严格模式。类型混淆可能导致数据损坏,一条配置就能避免。
2. 字段约束,替代自定义校验器
写自定义校验器之前,先问问自己:Field()的约束够不够?很多教程直接跳到@field_validator,其实内置约束已经够用:
from pydantic import BaseModel, Field
classCreateUser(BaseModel):
username: str = Field(
min_length=3,
max_length=30,
pattern=r'^[a-zA-Z0-9_]+$'# 只允许字母、数字、下划线
)
email: str = Field(
max_length=255,
# 不需要写邮箱格式校验器,Pydantic内置的EmailStr就能搞定
)
age: int = Field(ge=13, le=120) # 13到120岁
bio: str | None = Field(default=None, max_length=500)
referral_code: str | None = Field(default=None, min_length=8, max_length=8)
pattern参数非常强大。一个正则表达式就能完成用户名格式校验,不用单独写校验器。ge(大于等于)、le(小于等于)、gt(大于)、lt(小于)覆盖所有数值范围。
更重要的是,这些约束会自动出现在OpenAPI文档中。你的Swagger UI会显示最小长度、正则规则、数值范围。自定义校验器可没这待遇。
3. 为创建、更新、响应分别建模型
这个模式对我们代码库的“清爽度”提升最大。不要试图用一个模型包揽所有操作,而是按操作类型分别建模:
from pydantic import BaseModel, Field, EmailStr
from datetime import datetime
# 客户端创建用户时传的数据
classUserCreate(BaseModel):
username: str = Field(min_length=3, max_length=30)
email: EmailStr
password: str = Field(min_length=8)
# 客户端更新用户时传的数据
classUserUpdate(BaseModel):
username: str | None = Field(default=None, min_length=3, max_length=30)
email: EmailStr | None = None
bio: str | None = Field(default=None, max_length=500)
# 注意:没有password字段——密码修改走单独接口
# API返回的数据
classUserResponse(BaseModel):
id: int
username: str
email: str
bio: str | None
created_at: datetime
# 注意:没有password字段——永远不要暴露
model_config = ConfigDict(from_attributes=True)
然后在端点中使用:
@app.post("/users", response_model=UserResponse)
defcreate_user(data: UserCreate, db: Session = Depends(get_db)):
user = User(**data.model_dump())
user.password = hash_password(data.password)
db.add(user)
db.commit()
return user
@app.patch("/users/{user_id}", response_model=UserResponse)
defupdate_user(user_id: int, data: UserUpdate, db: Session = Depends(get_db)):
user = db.query(User).get(user_id)
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(user, key, value)
db.commit()
return user
更新端点里的关键技巧:model_dump(exclude_unset=True)。这只会返回客户端实际传的字段。如果只传了{"bio": "新简介"},就只更新bio字段,username和email保持不变。不加这个参数,没传的字段会被设为None,这绝对不是你想要的效果。
响应模型里的from_attributes=True告诉Pydantic,它可以从ORM对象(比如SQLAlchemy模型实例)读取数据,而不仅仅是字典。没有这个配置,直接返回SQLAlchemy对象会报错。
4. 用model_validator做跨字段校验
有时候单个字段是有效的,但字段组合起来就有问题。这时就该@model_validator出场了——我认为这是最被低估的Pydantic特性:
from pydantic import BaseModel, model_validator
from datetime import date
classDateRange(BaseModel):
start_date: date
end_date: date
@model_validator(mode='after')
defvalidate_date_range(self):
if self.end_date <= self.start_date:
raise ValueError('结束日期必须在开始日期之后')
if (self.end_date - self.start_date).days > 365:
raise ValueError('日期范围不能超过365天')
return self
classDiscountRule(BaseModel):
discount_type: str # "percentage" 或 "fixed"
discount_value: float
@model_validator(mode='after')
defvalidate_discount(self):
if self.discount_type == "percentage"andnot (0 < self.discount_value <= 100):
raise ValueError('百分比折扣必须在0到100之间')
if self.discount_type == "fixed"and self.discount_value <= 0:
raise ValueError('固定折扣必须为正数')
return self
mode='after'表示在所有字段校验通过后才执行。你会得到一个完整的模型实例,可以检查字段之间的关系。
还有mode='before',在字段校验前执行——适合做数据预处理:
classFlexibleUserInput(BaseModel):
name: str
email: str
@model_validator(mode='before')
@classmethod
defnormalize_input(cls, data):
if isinstance(data, dict):
# 对所有字符串字段去除首尾空格
for key, value in data.items():
if isinstance(value, str):
data[key] = value.strip()
# 字段校验前,将邮箱转为小写
if'email'in data and isinstance(data['email'], str):
data['email'] = data['email'].lower()
return data
现在每个字符串字段都会自动trim,邮箱统一小写——在字段校验之前就完成。你会发现,很多Bug都来自前导空格或大小写不一致的邮箱。
5. 自定义错误信息,让前端同学感激你
FastAPI默认的422错误响应,技术上正确,但对前端开发来说不够友好。
长这样:
{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "password"],
"msg": "String should have at least 8 characters",
"input": "abc"
}
]
}
结构对机器来说OK,但前端同学更想要更友好的。下面是我自定义校验错误处理的方式:
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
@app.exception_handler(RequestValidationError)
asyncdefcustom_validation_handler(request: Request, exc: RequestValidationError):
errors = {}
for error in exc.errors():
# 从location元组中获取字段名
field = error['loc'][-1] if error['loc'] else'unknown'
# 使用人类可读的错误信息
errors[field] = error['msg']
return JSONResponse(
status_code=422,
content={
"success": False,
"message": "校验失败",
"errors": errors
}
)
现在响应变成:
{
"success": false,
"message": "校验失败",
"errors": {
"password": "String should have at least 8 characters",
"email": "value is not a valid email address"
}
}
干净、可预测,前端可以轻松映射到表单字段。我的前端同事曾经专门来感谢这个模式——省去了他们解析嵌套数组、只为了在输入框旁边显示错误信息的工作。
6. 可复用字段类型,消灭重复代码
如果多个模型都需要校验手机号、slug或币种代码,别再重复定义了。用自定义注解类型,一次定义,到处复用:
from typing import Annotated
from pydantic import Field, AfterValidator
defvalidate_phone(value: str) -> str:
cleaned = ''.join(c for c in value if c.isdigit() or c == '+')
ifnot (10 <= len(cleaned) <= 15):
raise ValueError('手机号必须是10-15位数字')
return cleaned
defvalidate_slug(value: str) -> str:
import re
ifnot re.match(r'^[a-z0-9]+(?:-[a-z0-9]+)*$', value):
raise ValueError('slug必须是小写字母数字,单词间用连字符连接')
return value
defvalidate_currency_code(value: str) -> str:
valid_currencies = {'USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CNY'}
upper = value.upper()
if upper notin valid_currencies:
raise ValueError(f'币种必须是以下之一: {", ".join(sorted(valid_currencies))}')
return upper
# 定义可复用类型
PhoneNumber = Annotated[str, AfterValidator(validate_phone)]
Slug = Annotated[str, Field(min_length=1, max_length=100), AfterValidator(validate_slug)]
CurrencyCode = Annotated[str, AfterValidator(validate_currency_code)]
# 在整个项目中复用
classUserProfile(BaseModel):
phone: PhoneNumber
website_slug: Slug
classPayment(BaseModel):
amount: float = Field(gt=0)
currency: CurrencyCode
classMerchant(BaseModel):
support_phone: PhoneNumber
default_currency: CurrencyCode
store_slug: Slug
PhoneNumber、Slug、CurrencyCode现在都是可复用类型。当校验逻辑需要调整——比如新增支持的币种——只需要改一处,所有使用CurrencyCode的模型都会自动更新。这比在十个不同模型里复制粘贴校验器要干净得多。
7. 禁止额外字段,别让脏数据进来
默认情况下,Pydantic会悄悄忽略它不认识的字段。如果你的模型期望name和email,客户端传了name、email和is_admin: true——Pydantic会直接扔掉is_admin,不告诉任何人。
这是个安全隐患。恶意客户端可以通过传额外字段来探测API,寄希望于某次代码变更后某个字段被意外放行。
from pydantic import BaseModel, ConfigDict
classSecureUserCreate(BaseModel):
model_config = ConfigDict(extra='forbid')
username: str
email: str
password: str
# 现在这会触发校验错误
SecureUserCreate(username="alice", email="[email protected]", password="12345678", is_admin=True)
# ValidationError: Extra inputs are not permitted
extra='forbid'会拒绝任何未在模型中明确定义的字段。我在所有处理敏感操作的模型上都用这个配置——用户创建、支付处理、角色分配。一行配置,堵住一类潜在问题。
8. 嵌套模型,优雅处理复杂JSON
真实API经常处理嵌套数据。订单包含商品项,用户有多个地址。
别把所有字段扁平化到一个大模型里——用嵌套:
from pydantic import BaseModel, Field
classOrderItem(BaseModel):
product_id: int
quantity: int = Field(gt=0, le=100)
unit_price: float = Field(gt=0)
classShippingAddress(BaseModel):
street: str = Field(min_length=5)
city: str
postal_code: str = Field(pattern=r'^\d{5}(-\d{4})?$')
country: str = Field(min_length=2, max_length=2)
classCreateOrder(BaseModel):
model_config = ConfigDict(extra='forbid')
customer_id: int
items: list[OrderItem] = Field(min_length=1, max_length=50)
shipping: ShippingAddress
notes: str | None = Field(default=None, max_length=500)
@model_validator(mode='after')
defvalidate_order(self):
total = sum(item.quantity * item.unit_price for item in self.items)
if total > 10000:
raise ValueError(f'订单总金额 ${total:.2f} 超过最大限额 $10,000')
return self
每一层独立校验。如果items[2].quantity是负数,你会收到精确的错误信息,指向body → items → 2 → quantity。如果邮政编码格式不对,错误在body → shipping → postal_code。前端能得到精确的错误位置,不用猜。
min_length=1保证不能提交空订单,max_length=50防止有人提交一千个商品压垮系统。这些小约束能避免很多Bug。
9. 响应模型的计算字段
有时响应里需要一些计算字段,而不是数据库存储的字段。Pydantic V2的@computed_field正好用:
from pydantic import BaseModel, computed_field
from datetime import datetime, timezone
classOrderResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
items: list[OrderItemResponse]
created_at: datetime
status: str
@computed_field
@property
deftotal(self) -> float:
return sum(item.quantity * item.unit_price for item in self.items)
@computed_field
@property
defitem_count(self) -> int:
return sum(item.quantity for item in self.items)
@computed_field
@property
defage_hours(self) -> float:
delta = datetime.now(timezone.utc) - self.created_at
return round(delta.total_seconds() / 3600, 1)
total、item_count、age_hours并不存储在数据库里。它们在模型序列化时动态计算,出现在JSON响应和OpenAPI文档中——但你永远不需要手动维护这些值与底层数据的一致性。
V2之前,得用@validator的hack,或者在端点里手动计算后传进去。计算字段让这一切变得干净。
10. 带判别器的联合类型,处理多态数据
这是最进阶的模式,但解决的问题比想象中更常见:一个端点需要接收不同类型的数据,具体类型由某个字段决定。
比如一个通知设置接口,不同通知渠道的payload结构不同:
from pydantic import BaseModel, Field
from typing import Literal, Union
from typing import Annotated
classEmailNotification(BaseModel):
channel: Literal['email']
email_address: str
subject_prefix: str | None = None
classSlackNotification(BaseModel):
channel: Literal['slack']
webhook_url: str
mention_users: list[str] = []
classSMSNotification(BaseModel):
channel: Literal['sms']
phone_number: str
max_length: int = Field(default=160, le=500)
NotificationConfig = Annotated[
Union[EmailNotification, SlackNotification, SMSNotification],
Field(discriminator='channel')
]
classUpdateNotificationSettings(BaseModel):
user_id: int
notifications: list[NotificationConfig]
@app.put("/settings/notifications")
defupdate_notifications(data: UpdateNotificationSettings):
for notification in data.notifications:
match notification.channel:
case 'email':
setup_email(notification.email_address)
case 'slack':
setup_slack(notification.webhook_url)
case 'sms':
setup_sms(notification.phone_number)
return {"updated": len(data.notifications)}
discriminator='channel'告诉Pydantic:先看channel字段,然后用它决定用哪个模型校验。如果有人传{"channel": "email", "webhook_url": "..."},Pydantic会用EmailNotification校验——而webhook_url在这个模型里不存在,校验就会失败。
没有带判别器的联合类型,你可能得弄一个塞满可选字段的臃肿模型,或者在端点里写一堆if/else逻辑。这个模式保证了类型安全、文档清晰、校验自动完成。
写在最后
Pydantic是每个FastAPI应用的隐形骨架。大多数开发者只学了皮毛——定义一个模型、加类型提示、让FastAPI自动校验——然后就停步了。但基础API和生产级API的差距,往往就在这些细节里:敏感数据的严格模式、按操作分离模型、自定义错误处理、可复用类型、计算字段……
我反复验证的一个模式是:尽早校验、严格校验、只校验一次。把所有能塞进Pydantic模型的业务规则都塞进去。等到你的端点代码运行时,数据应该已经是干净的、类型安全的、可信的。你的端点不应该是第一道防线——你的模型才是。
开头那个"price": "29.99"的Bug?如果用了严格模式,根本过不了校验。一行配置:ConfigDict(strict=True)。这就是“能用”和“敢用”的API之间的差距。
核心回顾:
严格模式: ConfigDict(strict=True)阻止自动类型转换,尤其适合金额、数量等敏感字段。模型分离:Create、Update、Response各建模型,配合 exclude_unset=True实现优雅的局部更新。跨字段校验:用 @model_validator处理字段组合逻辑,mode='before'做预处理,mode='after'做关系校验。
你在生产API中遇到过最奇怪的输入是什么?我遇到过JSON字段里嵌套YAML的情况。欢迎在评论区分享你的“神奇”经历。
🏴☠️宝藏级🏴☠️ 原创公众号『数据STUDIO』内容超级硬核。公众号以Python为核心语言,垂直于数据科学领域,包括可戳👉Python|MySQL|数据分析|数据可视化|机器学习与数据挖掘|爬虫等,从入门到进阶!
长按👇关注- 数据STUDIO -设为星标,干货速递