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:
patternbuộcskukhớp regex, ví dụABC-1234.Quantitylà một kiểu tái sử dụng được nhờAnnotated. Mọi field khai báoQuantityđều phải nằm trong khoảng(0, 100].unit_pricedùngDecimalđể 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ênvchắc chắn làstr.mode="before", nhưnormalize_phone: chạy trước bước kiểm tra kiểu vàpattern.vcó thể là bất cứ thứ gì, nên bạn phải kiểm traisinstance. Ở đây ta chuẩn hóa+84 912 345 678thành0912345678, sau đópatternmớ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_validatorphả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 choint.str_strip_whitespace=True: tự cắt khoảng trắng hai đầu chuỗi.frozen=True: instance bất biến. Gánpayment.amount = 0sẽ 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 đó,
dataclasshoặcNamedTuplephù 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.