数据STUDIO

FastAPI请求验证:10 个 Pydantic进阶技巧

Image
你的API安全吗?90%的团队都忽略了这一点

我们曾有一个支付接口,接收一个包含price字段的JSON请求。类型提示是float,标准的Pydantic模型。完美运行了好几个月。

直到有一天,一个前端开发同学传了"price": "29.99"——一个字符串,而不是数字。Pydantic居然接受了。它悄无声息地将字符串转换成了29.99,继续执行。没有报错,没有警告。请求照常通过了。

这不是Bug,这是Pydantic的默认行为。它会在背后帮你做类型转换。看起来像数字的字符串就转成数字,"true"变成True,整数1变成浮点1.0。它的设计初衷是“宽容”。

对很多API来说,这没问题。但支付接口不行。悄无声息地接受本该是数字的字符串,就像定时炸弹。我们需要严格校验——也就是从那天起,我意识到大多数Pydantic教程只触及了冰山一角。

Image

下面是过去几年在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. 为创建、更新、响应分别建模型

这个模式对我们代码库的“清爽度”提升最大。不要试图用一个模型包揽所有操作,而是按操作类型分别建模:

Image

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错误响应,技术上正确,但对前端开发来说不够友好。

Image

长这样:

{
"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经常处理嵌套数据。订单包含商品项,用户有多个地址。

Image

别把所有字段扁平化到一个大模型里——用嵌套:

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. 带判别器的联合类型,处理多态数据

这是最进阶的模式,但解决的问题比想象中更常见:一个端点需要接收不同类型的数据,具体类型由某个字段决定。

Image

比如一个通知设置接口,不同通知渠道的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的差距,往往就在这些细节里:敏感数据的严格模式、按操作分离模型、自定义错误处理、可复用类型、计算字段……

Image

我反复验证的一个模式是:尽早校验、严格校验、只校验一次。把所有能塞进Pydantic模型的业务规则都塞进去。等到你的端点代码运行时,数据应该已经是干净的、类型安全的、可信的。你的端点不应该是第一道防线——你的模型才是。

开头那个"price": "29.99"的Bug?如果用了严格模式,根本过不了校验。一行配置:ConfigDict(strict=True)。这就是“能用”和“敢用”的API之间的差距。

核心回顾:

  1. 严格模式:ConfigDict(strict=True)阻止自动类型转换,尤其适合金额、数量等敏感字段。
  2. 模型分离:Create、Update、Response各建模型,配合exclude_unset=True实现优雅的局部更新。
  3. 跨字段校验:用@model_validator处理字段组合逻辑,mode='before'做预处理,mode='after'做关系校验。

你在生产API中遇到过最奇怪的输入是什么?我遇到过JSON字段里嵌套YAML的情况。欢迎在评论区分享你的“神奇”经历。

🏴‍☠️宝藏级🏴‍☠️ 原创公众号『数据STUDIO』内容超级硬核。公众号以Python为核心语言,垂直于数据科学领域,包括可戳👉Python|MySQL|数据分析|数据可视化|机器学习与数据挖掘|爬虫等,从入门到进阶!

长按👇关注- 数据STUDIO -设为星标,干货速递ImageImage