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确保你应用的核心只处理已清理和正确类型的数据。

pythonpydanticvalidationpytest