Pydantic v2: validate dữ liệu đầu vào trong Python

1. Tổng quan

Dữ liệu từ request HTTP, file JSON hay message queue thường sai kiểu, thiếu field hoặc có giá trị vô lý. Nếu bạn kiểm tra bằng if/else rải rác thì code sẽ nhanh chóng rối. Pydantic v2 cho phép bạn mô tả “hình dạng” dữ liệu bằng type hint, rồi tự động parse, ép kiểu và báo lỗi chi tiết.

Bài này đi qua các công cụ chính của Pydantic v2: BaseModel, Field, field_validator, model_validator, ConfigDict, TypeAdapter. Phần cuối là cách viết test bằng pytest. Đọc xong, bạn sẽ tự xây được các model validate đầu vào an toàn cho ứng dụng của mình.

2. Cài đặt

Bài viết dùng Python 3.11+ và Pydantic 2.x:

pip install "pydantic>=2.7" pytest

Nếu cần kiểu EmailStr, bạn cài thêm phần phụ trợ:

pip install "pydantic[email]"

3. Model cơ bản với BaseModel

3.1. Khai báo và ép kiểu

Một model là một class kế thừa BaseModel. Mỗi thuộc tính có type hint là một field:

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'>

Bạn truyền id="42" dưới dạng chuỗi, nhưng Pydantic tự chuyển thành int. Đây là lax mode, chế độ mặc định: Pydantic ép kiểu nếu việc chuyển đổi hợp lý. Field is_active có giá trị mặc định nên không bắt buộc phải truyền.

3.2. Xử lý ValidationError

Khi dữ liệu sai, Pydantic ném ValidationError và gom tất cả lỗi một lần, không dừng ở lỗi đầu tiên:

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

Mỗi lỗi là một dict, gồm loc (vị trí field), type (mã lỗi ổn định) và msg (thông báo cho người đọc). Khi trả lỗi cho client, bạn nên dựa vào loc và type. Nội dung msg có thể thay đổi giữa các phiên bản.

4. Ràng buộc giá trị với Field và Annotated

Type hint chỉ kiểm tra kiểu. Để giới hạn giá trị, bạn dùng 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')

Giải thích:

  • pattern buộc sku khớp regex, ví dụ ABC-1234.
  • Quantity là một kiểu tái sử dụng được nhờ Annotated. Mọi field khai báo Quantity đều phải nằm trong khoảng (0, 100].
  • unit_price dùng Decimal để tránh sai số dấu phẩy động. Giá trị phải dương và có tối đa 2 chữ số thập phân.

Thử truyền dữ liệu sai:

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

Một số ràng buộc hay dùng khác: min_length/max_length cho chuỗi và list, ge/lt cho số, multiple_of cho bội số.

5. Validator tùy chỉnh với field_validator

Khi ràng buộc phức tạp hơn regex hay khoảng giá trị, bạn viết validator riêng:

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'

Có hai chế độ cần phân biệt:

  • mode="after" (mặc định), như clean_name: chạy sau khi Pydantic đã kiểm tra kiểu, nên v chắc chắn là str.
  • mode="before", như normalize_phone: chạy trước bước kiểm tra kiểu và pattern. v có thể là bất cứ thứ gì, nên bạn phải kiểm tra isinstance. Ở đây ta chuẩn hóa +84 912 345 678 thành 0912345678, sau đó pattern mới được áp dụng.

Validator phải trả về giá trị. Giá trị trả về chính là giá trị được lưu vào field. Khi muốn báo lỗi, bạn ném ValueError hoặc AssertionError. Pydantic sẽ bọc nó thành lỗi value_error, với thông báo "Value error, name không được rỗng".

Lưu ý: Thứ tự decorator rất quan trọng. @field_validator phải nằm trên @classmethod. Nếu đảo ngược, bạn sẽ gặp lỗi khó hiểu ngay lúc định nghĩa class.

6. Validate nhiều field cùng lúc với model_validator

Một số quy tắc liên quan đến nhiều field, ví dụ ngày trả phòng phải sau ngày nhận phòng. Trường hợp này cần 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

Với mode="after", validator nhận một instance đã hợp lệ từng field. Bạn truy cập self.check_in như thuộc tính bình thường và phải return self. Lỗi có loc rỗng () vì nó thuộc về cả model, không gắn với field nào. Chuỗi "2026-10-20" cũng được tự động parse thành date.

7. Cấu hình model với ConfigDict

model_config điều khiển hành vi chung của model. Đây là cấu hình hay dùng cho dữ liệu nhạy cảm như thanh toán:

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

Ý nghĩa từng tùy chọn:

  • extra="forbid": từ chối field lạ như coupon. Mặc định là "ignore", tức Pydantic im lặng bỏ qua field thừa. Điều này dễ che giấu lỗi gõ sai tên field phía client.
  • strict=True: tắt ép kiểu. "1" không còn được chấp nhận cho int.
  • str_strip_whitespace=True: tự cắt khoảng trắng hai đầu chuỗi.
  • frozen=True: instance bất biến. Gán payment.amount = 0 sẽ ném lỗi.

Nếu chỉ muốn strict cho một vài field, bạn dùng Field(strict=True) hoặc các kiểu như StrictInt, StrictStr.

Một tùy chọn khác đáng biết là validate_assignment=True. Mặc định, Pydantic không validate khi bạn gán lại thuộc tính sau khi tạo object. Bật tùy chọn này lên để giữ model luôn hợp lệ.

8. Validate từ dict, JSON và kiểu bất kỳ

Trong thực tế, dữ liệu thường đến dưới dạng dict hoặc chuỗi JSON. Pydantic v2 cung cấp hai class method:

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}

Nếu đầu vào là chuỗi JSON, bạn nên dùng model_validate_json thay vì json.loads rồi model_validate. Cách này nhanh hơn vì phần lõi Rust của Pydantic parse và validate trong một bước. Chiều ngược lại, model_dump() trả về dict, còn model_dump_json() trả về chuỗi JSON.

Khi cần validate một kiểu không phải model, ví dụ list[OrderItem], bạn dùng 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

Bạn nên tạo TypeAdapter một lần ở cấp module rồi tái sử dụng. Việc khởi tạo có chi phí build schema, không nên lặp lại trong mỗi request.

9. Viết test với pytest

Validation là logic nghiệp vụ, nên cần được test. Giả sử các model ở trên nằm trong file 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")

Chạy test:

pytest -q
.....                                                    [100%]
5 passed

Lưu ý các test assert trên loc và type, không so sánh nguyên văn msg. Nhờ vậy, test không vỡ khi Pydantic đổi câu chữ thông báo ở phiên bản mới. parametrize giúp bạn phủ cả hai biên 0 và 101 chỉ với một hàm.

10. Lỗi hay gặp và khi nào không nên dùng

Dùng nhầm API của v1. Nhiều tài liệu cũ vẫn dùng cú pháp v1. Bảng đối chiếu nhanh:

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 cũ vẫn chạy nhưng phát DeprecationWarning. Bạn nên chuyển hẳn sang API mới.

Bất ngờ với lax mode. Lax mode chấp nhận "yes" thành True và 1.0 thành 1. Với dữ liệu tài chính hoặc ID, hành vi này có thể không mong muốn. Khi đó hãy bật strict.

Quên return trong validator. Một field_validator không có return sẽ khiến field nhận giá trị None. Một model_validator(mode="after") thiếu return self cũng gây lỗi.

Bỏ qua validation mà không biết. model_construct() tạo instance không qua validation. Phương thức này chỉ dành cho dữ liệu bạn đã tin tưởng tuyệt đối, ví dụ đọc lại từ chính database của mình.

Lưu ý: Không nên dùng Pydantic cho mọi object nội bộ. Ở các vòng lặp nóng xử lý hàng triệu object đã sạch, chi phí validation là thừa. Khi đó, dataclass hoặc NamedTuple phù hợp hơn. Hãy đặt Pydantic ở biên của hệ thống: API, file cấu hình, message từ hệ thống khác.

11. Kết luận

Pydantic v2 biến việc validate dữ liệu đầu vào thành khai báo thay vì viết lệnh. Bạn mô tả kiểu bằng type hint, thêm ràng buộc bằng Field và Annotated, viết quy tắc riêng bằng field_validator và model_validator. Hành vi chung như cấm field thừa hay tắt ép kiểu được điều khiển qua ConfigDict.

Ba điểm cần nhớ: dùng model_validate_json cho đầu vào JSON, bật extra="forbid" với dữ liệu quan trọng, và test validation dựa trên loc/type thay vì nội dung thông báo. Áp dụng đúng chỗ, ở biên hệ thống, Pydantic giúp phần lõi ứng dụng của bạn chỉ phải làm việc với dữ liệu đã sạch và đúng kiểu.

pythonpydanticvalidationpytest