Pydantic v2:验证Python中的输入数据
1. 概述
来自HTTP请求、JSON文件或消息队列的数据常常类型错误、字段缺失或值不合理。如果你用if/else分散地检查,代码会快速变得混乱。Pydantic v2允许你用类型提示描述数据的“形态”,然后自动解析、强制转换和报告详细错误。
本文介绍Pydantic v2的主要工具:BaseModel、Field、field_validator、model_validator、ConfigDict、TypeAdapter。最后是如何用pytest编写测试。读完后,你可以为自己的应用构建安全的输入验证模型。
2. 安装
本文使用Python 3.11+和Pydantic 2.x:
pip install "pydantic>=2.7" pytest
如果需要EmailStr类型,请安装额外的支持:
pip install "pydantic[email]"
3. 使用BaseModel的基础模型
3.1. 声明和类型强制
模型是继承BaseModel的类。每个带类型提示的属性都是一个字段:
from pydantic import BaseModel
class Customer(BaseModel):
id: int
name: str
email: str
is_active: bool = True
customer = Customer(id="42", name="An", email="an@example.com")
print(customer)
print(customer.id, type(customer.id))
id=42 name='An' email='an@example.com' is_active=True
42 <class 'int'>
你传入id="42"作为字符串,但Pydantic会自动转换为int。这是lax mode,默认模式:如果转换合理,Pydantic会强制类型转换。is_active字段有默认值,所以不必传入。
3.2. 处理ValidationError
当数据有误时,Pydantic会抛出ValidationError并一次性收集所有错误,而不是在第一个错误处停止:
from pydantic import ValidationError
try:
Customer(id="abc", name="An")
except ValidationError as e:
print(e.error_count())
for err in e.errors():
print(err["loc"], err["type"], err["msg"])
2
('id',) int_parsing Input should be a valid integer, unable to parse string as an integer
('email',) missing Field required
每个错误是一个dict,包含loc(字段位置)、type(稳定的错误代码)和msg(人类可读的消息)。向客户端返回错误时,应该依赖loc和type。msg的内容可能在版本之间变化。
4. 使用Field和Annotated限制值
类型提示只检查类型。要限制值,请使用Field:
from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, Field
Quantity = Annotated[int, Field(gt=0, le=100)]
class OrderItem(BaseModel):
sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$")
quantity: Quantity
unit_price: Decimal = Field(gt=0, max_digits=10, decimal_places=2)
item = OrderItem(sku="ABC-1234", quantity=3, unit_price="19.90")
print(item)
sku='ABC-1234' quantity=3 unit_price=Decimal('19.90')
解释:
pattern强制sku匹配正则表达式,例如ABC-1234。Quantity是一个可重用的类型,得益于Annotated。任何声明为Quantity的字段都必须在范围(0, 100]内。unit_price使用Decimal以避免浮点误差。值必须为正且最多有2位小数。
尝试传入错误的数据:
try:
OrderItem(sku="abc", quantity=0, unit_price="10")
except ValidationError as e:
for err in e.errors():
print(err["loc"], err["type"])
('sku',) string_pattern_mismatch
('quantity',) greater_than
其他常用的约束:min_length/max_length用于字符串和列表,ge/lt用于数字,multiple_of用于倍数。
5. 使用field_validator的自定义验证器
当约束比正则表达式或值范围更复杂时,编写自定义验证器:
from pydantic import BaseModel, Field, field_validator
class Contact(BaseModel):
name: str
phone: str = Field(pattern=r"^0\d{9}$")
@field_validator("name")
@classmethod
def clean_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("name không được rỗng")
return v.title()
@field_validator("phone", mode="before")
@classmethod
def normalize_phone(cls, v):
if isinstance(v, str):
v = v.replace(" ", "").replace(".", "")
if v.startswith("+84"):
v = "0" + v[3:]
return v
contact = Contact(name=" nguyễn văn an ", phone="+84 912 345 678")
print(contact)
name='Nguyễn Văn An' phone='0912345678'
有两种模式需要区分:
mode="after"(默认),如clean_name:在Pydantic检查类型之后运行,所以v肯定是str。mode="before",如normalize_phone:在类型检查和pattern之前运行。v可以是任何东西,所以你必须检查isinstance。这里我们将+84 912 345 678规范化为0912345678,然后应用pattern。
验证器必须返回一个值。返回的值就是存储在字段中的值。当想报错时,抛出ValueError或AssertionError。Pydantic会将其包装为value_error,消息为"Value error, name không được rỗng"。
注意: 装饰器的顺序非常重要。
@field_validator必须在@classmethod上面。如果颠倒,定义类时会立即遇到令人困惑的错误。
6. 使用model_validator同时验证多个字段
某些规则涉及多个字段,例如退房日期必须在入住日期之后。这种情况需要model_validator:
from datetime import date
from typing import Self
from pydantic import BaseModel, ValidationError, model_validator
class Booking(BaseModel):
room_id: int
check_in: date
check_out: date
@model_validator(mode="after")
def check_dates(self) -> Self:
if self.check_out <= self.check_in:
raise ValueError("check_out phải sau check_in")
return self
try:
Booking(room_id=101, check_in="2026-10-20", check_out="2026-10-18")
except ValidationError as e:
err = e.errors()[0]
print(err["loc"], err["msg"])
() Value error, check_out phải sau check_in
对于mode="after",验证器接收一个已经通过字段验证的实例。你像访问普通属性一样访问self.check_in并必须return self。错误的loc为空()因为它属于整个模型,而不是任何特定字段。字符串"2026-10-20"也会自动解析为date。
7. 使用ConfigDict配置模型
model_config控制模型的整体行为。这些是用于支付等敏感数据的常见配置:
from pydantic import BaseModel, ConfigDict, ValidationError
class PaymentRequest(BaseModel):
model_config = ConfigDict(
extra="forbid",
strict=True,
str_strip_whitespace=True,
frozen=True,
)
order_id: int
amount: int
currency: str
try:
PaymentRequest(order_id="1", amount=50000, currency="VND", coupon="FREE")
except ValidationError as e:
for err in e.errors():
print(err["loc"], err["type"])
('order_id',) int_type
('coupon',) extra_forbidden
每个选项的含义:
extra="forbid":拒绝陌生字段如coupon。默认为"ignore",即Pydantic默默忽略多余字段。这很容易隐藏客户端拼写错误的字段名。strict=True:禁用类型强制转换。"1"不再被接受为int。str_strip_whitespace=True:自动去除字符串两端的空白。frozen=True:实例不可变。赋值payment.amount = 0会抛出错误。
如果只想对某些字段进行strict,可以使用Field(strict=True)或类型如StrictInt、StrictStr。
另一个值得注意的选项是validate_assignment=True。默认情况下,Pydantic在创建对象后重新赋值属性时不会验证。启用此选项以保持模型始终有效。
8. 从dict、JSON和任意类型验证
在实践中,数据通常以dict或JSON字符串的形式到达。Pydantic v2提供两个类方法:
raw_json = '{"id": 7, "name": "Bình", "email": "binh@example.com"}'
customer = Customer.model_validate_json(raw_json)
raw_dict = {"id": 8, "name": "Chi", "email": "chi@example.com", "is_active": False}
customer2 = Customer.model_validate(raw_dict)
print(customer.model_dump())
print(customer2.model_dump_json())
{'id': 7, 'name': 'Bình', 'email': 'binh@example.com', 'is_active': True}
{"id":8,"name":"Chi","email":"chi@example.com","is_active":false}
如果输入是JSON字符串,应该使用model_validate_json而不是先json.loads再model_validate。这样更快因为Pydantic的Rust核心在一步中解析和验证。反过来,model_dump()返回dict,而model_dump_json()返回JSON字符串。
当需要验证非模型的类型(如list[OrderItem])时,使用TypeAdapter:
from pydantic import TypeAdapter
items_adapter = TypeAdapter(list[OrderItem])
items = items_adapter.validate_python([
{"sku": "ABC-0001", "quantity": 2, "unit_price": "5.00"},
{"sku": "XYZ-0002", "quantity": 1, "unit_price": "12.50"},
])
print(len(items), items[1].unit_price)
2 12.50
应该在模块级别创建一次TypeAdapter,然后重用。初始化有构建schema的成本,不应该在每个请求中重复。
9. 使用pytest编写测试
验证是业务逻辑,应该被测试。假设上面的模型在models.py文件中:
from decimal import Decimal
import pytest
from pydantic import ValidationError
from models import Booking, Contact, OrderItem
def test_order_item_coerces_valid_input():
item = OrderItem(sku="ABC-1234", quantity="3", unit_price="19.90")
assert item.quantity == 3
assert item.unit_price == Decimal("19.90")
@pytest.mark.parametrize("quantity", [0, -1, 101])
def test_order_item_rejects_out_of_range_quantity(quantity):
with pytest.raises(ValidationError) as exc_info:
OrderItem(sku="ABC-1234", quantity=quantity, unit_price="10")
error = exc_info.value.errors()[0]
assert error["loc"] == ("quantity",)
assert error["type"] in {"greater_than", "less_than_equal"}
def test_contact_normalizes_phone():
contact = Contact(name="an", phone="+84 912.345.678")
assert contact.phone == "0912345678"
assert contact.name == "An"
def test_booking_rejects_checkout_before_checkin():
with pytest.raises(ValidationError, match="check_out phải sau check_in"):
Booking(room_id=1, check_in="2026-10-20", check_out="2026-10-20")
运行测试:
pytest -q
..... [100%]
5 passed
注意测试对loc和type进行断言,而不是比较原文msg。这样,当Pydantic在新版本中更改消息措辞时,测试不会中断。parametrize帮助你用一个函数覆盖两个边界0和101。
10. 常见错误和何时不应该使用
**混淆v1 API。**许多旧文档仍然使用v1语法。快速参考表:
v1 v2
@validator @field_validator
@root_validator @model_validator
class Config: ... model_config = ConfigDict(...)
.dict() / .json() .model_dump() / .model_dump_json()
.parse_obj() / .parse_raw() .model_validate() / .model_validate_json()
旧API仍然可以运行但会发出DeprecationWarning。你应该完全切换到新API。
对Lax mode感到惊讶。 Lax mode接受"yes"变成True和1.0变成1。对于财务数据或ID,这种行为可能不是所需的。在这种情况下,启用strict。
**忘记在验证器中return。**没有return的field_validator会使字段接收None值。缺少return self的model_validator(mode="after")也会导致错误。
无意中跳过验证。model_construct()创建实例不经过验证。此方法仅用于你完全信任的数据,例如从你自己的数据库读回。
注意: 不应该为所有内部对象使用Pydantic。在处理数百万已清理对象的热循环中,验证开销是多余的。在这种情况下,
dataclass或NamedTuple更合适。将Pydantic放在系统的边界上:API、配置文件、来自其他系统的消息。
11. 结论
Pydantic v2将验证输入数据从命令式编程转变为声明式。你用类型提示描述类型,用Field和Annotated添加约束,用field_validator和model_validator编写自定义规则。像禁止额外字段或禁用类型强制这样的全局行为通过ConfigDict控制。
三点要记住:对JSON输入使用model_validate_json,对重要数据启用extra="forbid",基于loc/type而非消息内容来测试验证。正确应用于系统边界,Pydantic确保你应用的核心只处理已清理和正确类型的数据。