Data API 3 — Validation & serialization với Pydantic
Ở bài trước ta đã dựng được endpoint FastAPI đầu tiên và thấy Pydantic xuất hiện ở request body lẫn response_model. Bài này đào sâu vào chính lớp đó. Với một service dữ liệu ngân hàng, validation không phải là "cho vui": một số tiền sai dấu, một mã tiền tệ lạ, hay một cột nội bộ bị lộ ra ngoài API đều là sự cố thật. Pydantic biến type hint Python thành một hàng rào kiểm tra dữ liệu ở biên hệ thống — dữ liệu bẩn bị chặn trước khi chạm vào logic nghiệp vụ hay database.
Toàn bộ bài dùng Pydantic v2 (bản đi kèm FastAPI hiện đại). Code là minh hoạ, không chạy trực tiếp trong sandbox.
1. BaseModel: hàng rào ở biên
Mọi thứ bắt đầu từ BaseModel. Bạn khai báo field bằng type hint, Pydantic lo phần còn lại: ép kiểu (coercion), kiểm tra ràng buộc, và báo lỗi có cấu trúc nếu dữ liệu sai.
from datetime import datetime
from pydantic import BaseModel
class TransactionIn(BaseModel):
account_id: int
amount: float
kind: str
created_at: datetime | None = None
Quy tắc field:
| Khai báo | Ý nghĩa |
|---|---|
account_id: int | Bắt buộc, phải ép được về int |
created_at: datetime | None = None | Optional, mặc định None |
kind: str = "debit" | Có default → không bắt buộc gửi lên |
tags: list[str] = [] | Với mutable default, Pydantic tự tạo bản sao mỗi instance (an toàn, khác list default của hàm Python) |
Điểm quan trọng: field không có default là bắt buộc. Nếu client không gửi account_id, Pydantic ném lỗi validation chứ không âm thầm gán None. Đây là khác biệt lớn so với dict thô — bạn không bao giờ phải viết data.get("account_id") rồi tự đoán.
Pydantic v2 mặc định ép kiểu "hợp lý": chuỗi "123" → int 123, chuỗi ISO → datetime. Nếu muốn nghiêm ngặt hơn (không ép), dùng model_config = ConfigDict(strict=True) hoặc kiểu StrictInt.
2. Field(): ràng buộc và metadata
Type hint nói kiểu, còn Field() nói ràng buộc. Đây là nơi mã hoá luật dữ liệu ngân hàng.
from decimal import Decimal
from pydantic import BaseModel, Field
class TransferRequest(BaseModel):
amount: Decimal = Field(gt=0, le=Decimal("1000000000"),
description="Số tiền chuyển, đơn vị VND")
account_no: str = Field(min_length=6, max_length=20,
pattern=r"^\d+$",
description="Số tài khoản, chỉ chứa chữ số")
note: str | None = Field(default=None, max_length=210)
idempotency_key: str = Field(alias="Idempotency-Key")
Các ràng buộc hay dùng:
| Tham số | Áp dụng cho | Ví dụ ngân hàng |
|---|---|---|
gt, ge, lt, le | số | amount phải gt=0 |
min_length, max_length | chuỗi/list | số tài khoản 6–20 ký tự |
pattern | chuỗi (regex) | mã tiền tệ, số tài khoản |
alias | mọi field | map Idempotency-Key (header) sang tên Python |
description | mọi field | hiển thị trong tài liệu OpenAPI |
description không chỉ để đẹp — nó chảy thẳng vào schema OpenAPI, nên trang /docs tự có chú thích cho từng field. Với alias, nhớ bật model_config = ConfigDict(populate_by_name=True) nếu muốn khởi tạo được cả bằng tên Python lẫn alias.
3. Kiểu nâng cao: đúng kiểu là nửa việc validation
Chọn đúng kiểu giúp Pydantic tự validate mà không cần viết thêm luật.
Decimalcho tiền tệ — đây là điểm sống còn.floatdùng dấu phẩy động nhị phân,0.1 + 0.2ra0.30000000000000004. Với số dư và giao dịch, sai số này tích luỹ thành lệch sổ. Luôn dùngDecimal, và có thể ràng buộc số chữ số thập phân bằngcondecimal(max_digits=18, decimal_places=2).EmailStr— validate định dạng email (cần càipydantic[email]).datetime,date— parse chuỗi ISO 8601 tự động, giữ timezone.Enum— giới hạn giá trị trong tập cố định. Lý tưởng chocurrency,kind, trạng thái.UUID— cho ID giao dịch/định danh, chặn chuỗi rác ngay từ đầu.
from decimal import Decimal
from enum import Enum
from uuid import UUID
from datetime import datetime
from pydantic import BaseModel, EmailStr, Field
class Currency(str, Enum):
VND = "VND"
USD = "USD"
EUR = "EUR"
class TxnKind(str, Enum):
debit = "debit"
credit = "credit"
class Transaction(BaseModel):
txn_id: UUID
account_id: int
amount: Decimal = Field(gt=0)
currency: Currency = Currency.VND
kind: TxnKind
email: EmailStr | None = None
created_at: datetime
Với Enum kế thừa str, giá trị JSON gửi lên/xuống vẫn là chuỗi "VND", nhưng trong code bạn có Currency.VND được kiểm tra chặt. Client gửi "vnd" hay "XYZ" → 422 ngay.
4. Validator: luật nghiệp vụ không nhét vừa vào Field
Có những luật Field() không diễn tả được: quan hệ giữa các field, chuẩn hoá dữ liệu, hay kiểm tra phức tạp. Đó là việc của validator.
field_validator — chạy trên một field:
from pydantic import BaseModel, field_validator
from decimal import Decimal
ALLOWED = {"VND", "USD", "EUR"}
class TransferRequest(BaseModel):
amount: Decimal
currency: str
@field_validator("currency")
@classmethod
def currency_uppercase_allowed(cls, v: str) -> str:
v = v.strip().upper()
if v not in ALLOWED:
raise ValueError(f"currency phải thuộc {ALLOWED}")
return v
@field_validator("amount")
@classmethod
def amount_positive(cls, v: Decimal) -> Decimal:
if v <= 0:
raise ValueError("amount phải lớn hơn 0")
return v
Lưu ý: validator vừa kiểm tra vừa chuẩn hoá (trả về giá trị đã upper()). Ném ValueError (hoặc AssertionError) là Pydantic tự gom vào lỗi 422 — không cần raise HTTPException thủ công.
model_validator — chạy trên toàn model, thấy được mọi field cùng lúc. Dùng cho luật liên field:
from pydantic import BaseModel, model_validator
class TransferRequest(BaseModel):
from_account: str
to_account: str
@model_validator(mode="after")
def different_accounts(self):
if self.from_account == self.to_account:
raise ValueError("Không thể chuyển cho chính tài khoản nguồn")
return self
mode="after" chạy sau khi từng field đã validate xong (nhận self đầy đủ kiểu). mode="before" chạy trên dữ liệu thô (dict) trước khi ép kiểu — hữu ích khi cần gộp/tách field đầu vào.
5. Nested model và list
Dữ liệu thật hiếm khi phẳng. Pydantic lồng model tự nhiên, validate đệ quy toàn bộ cây.
from pydantic import BaseModel
from decimal import Decimal
class LineItem(BaseModel):
account_no: str
amount: Decimal
class BatchTransfer(BaseModel):
batch_id: str
items: list[LineItem] # list các model con
total: Decimal
@model_validator(mode="after")
def total_matches(self):
if sum(i.amount for i in self.items) != self.total:
raise ValueError("total không khớp tổng các item")
return self
Nếu một item trong list sai, thông báo lỗi 422 chỉ rõ vị trí, ví dụ body.items.2.amount. Đây là lý do nên khai báo model lồng thay vì list[dict].
6. Serialization: model_dump và response_model
Validation lo dữ liệu vào, serialization lo dữ liệu ra. Hai chiều, hai mối quan tâm khác nhau.
model_dump() chuyển model thành dict; model_dump_json() thành chuỗi JSON. Vài tuỳ chọn hữu ích:
txn.model_dump() # dict đầy đủ
txn.model_dump(exclude={"internal_score"}) # bỏ field
txn.model_dump(exclude_none=True) # bỏ field None
txn.model_dump(by_alias=True) # dùng alias làm key
txn.model_dump(mode="json") # Decimal/UUID/datetime → dạng JSON-safe
Nhưng ở tầng API, công cụ chính là response_model. Khi bạn khai báo response_model=TransactionOut, FastAPI lọc dữ liệu handler trả về theo đúng schema đó — field nào không có trong TransactionOut sẽ bị cắt, kể cả khi object gốc có. Đây là cơ chế chống lộ dữ liệu thừa mạnh nhất:
class TransactionOut(BaseModel):
txn_id: UUID
amount: Decimal
currency: Currency
created_at: datetime
# KHÔNG có internal_risk_score, KHÔNG có raw_note nội bộ
@router.get("/transactions/{txn_id}", response_model=TransactionOut)
def get_txn(txn_id: UUID):
row = repo.fetch(txn_id) # object có 20 cột, gồm cả cột nhạy cảm
return row # FastAPI chỉ trả 4 field của TransactionOut
Nguyên tắc vàng: đừng bao giờ tin object trả về sẽ tự sạch. response_model là hàng rào cuối. Nếu quên nó và return row thô, mọi cột nội bộ có thể rò ra JSON.
7. Computed field
Đôi khi response cần một giá trị dẫn xuất, tính từ các field khác. Dùng @computed_field:
from pydantic import BaseModel, computed_field
from decimal import Decimal
class AccountOut(BaseModel):
balance: Decimal
hold_amount: Decimal
@computed_field
@property
def available_balance(self) -> Decimal:
return self.balance - self.hold_amount
available_balance không nằm trong dữ liệu vào nhưng xuất hiện trong model_dump() và JSON trả về. Nó cũng vào schema OpenAPI, nên client thấy được field này trong tài liệu.
8. Lỗi validation 422 và cấu trúc lỗi
Khi validation fail, FastAPI trả HTTP 422 Unprocessable Entity với body JSON có cấu trúc chuẩn:
{
"detail": [
{
"type": "greater_than",
"loc": ["body", "amount"],
"msg": "Input should be greater than 0",
"input": -50000
}
]
}
loc chỉ chính xác vị trí lỗi (đến tận body.items.2.amount cho list lồng), type là mã lỗi máy đọc được, msg là mô tả. Client (hay frontend) có thể map loc về đúng ô nhập liệu. Nếu cần định dạng lỗi riêng của tổ chức, ghi đè bằng @app.exception_handler(RequestValidationError).
9. Tách schema: request vs response vs DB model
Đây là nguyên tắc kiến trúc quan trọng nhất của bài, liên quan trực tiếp tới OOP & clean code và phân quyền truy cập dữ liệu. Đừng dùng chung một model cho mọi tầng. Ba tầng, ba trách nhiệm:
- Request schema (
...Create,...Update): những gì client được phép gửi. Không chứaid,created_at,internal_score— đó là những thứ server tự sinh. - Response schema (
...Out): những gì client được phép thấy. Không chứa cột nội bộ, không lộ khoá ngoại nhạy cảm. - DB model: ánh xạ đầy đủ bảng, gồm mọi cột nội bộ. Thứ này không bao giờ ra thẳng API.
class CustomerCreate(BaseModel): # client gửi
full_name: str
email: EmailStr
city: str
class CustomerOut(BaseModel): # client nhận
id: int
full_name: str
city: str
# KHÔNG có email nếu chính sách không cho lộ, KHÔNG có internal_kyc_flag
# DB model (SQLAlchemy) có thêm: internal_kyc_flag, risk_tier, raw_source...
Việc tách này chặn hai lỗi kinh điển cùng lúc: mass assignment (client nhét is_admin=true vào body update) và over-exposure (lộ cột nội bộ trong response). Sơ đồ bên dưới thể hiện luồng ba tầng.
10. Settings từ env với pydantic-settings
Cùng dòng máu Pydantic, pydantic-settings (gói riêng, pip install pydantic-settings) đọc cấu hình từ biến môi trường và validate như một model bình thường. Không hardcode secret trong code.
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="NCB_")
database_url: str
jwt_secret: str = Field(min_length=32)
max_page_size: int = 100
debug: bool = False
settings = Settings() # đọc NCB_DATABASE_URL, NCB_JWT_SECRET... từ env
Nếu thiếu NCB_DATABASE_URL hoặc jwt_secret ngắn hơn 32 ký tự, ứng dụng fail ngay lúc khởi động thay vì chạy được rồi mới sập giữa chừng. Đây là "fail fast" — sai cấu hình bị phát hiện lúc deploy, không phải lúc khách hàng đang chuyển tiền. Chi tiết dùng secret cho JWT sẽ ở bài bảo mật.
Use case thực tế
Bối cảnh: NCB mở endpoint POST /transfers cho app mobile khởi tạo lệnh chuyển khoản nội bộ. Trước khi có schema chặt, team gặp ba sự cố trong một quý: (1) một lệnh amount = -2000000 lọt qua và ghi âm số dư; (2) currency = "vnd" viết thường làm job đối soát cuối ngày lệch; (3) response vô tình trả cả cột internal_risk_score mà đội rủi ro không muốn lộ ra client.
Giải pháp bằng Pydantic, 4 lớp phòng thủ:
TransferRequest.amount: Decimal = Field(gt=0)— chặn số âm và số 0 ngay ở biên, trả 422. DùngDecimalnên không lệch số dư do sai số float.currency: Currency(Enum) +field_validatorchuẩn hoáupper()—"vnd"được chuẩn hoá về"VND","XYZ"bị từ chối.model_validatorkiểm trafrom_account != to_account— chặn chuyển vòng cho chính mình.response_model=TransferOutkhông khai báointernal_risk_score— FastAPI tự cắt, cột nội bộ không còn cách nào rò ra.
Kết quả: sau khi triển khai, ba nhóm lỗi trên về 0. Quan trọng hơn, mọi lỗi đầu vào giờ trả 422 có loc rõ ràng, đội frontend map thẳng về ô nhập, giảm thời gian xử lý ticket "chuyển tiền báo lỗi mà không biết vì sao". Toàn bộ ràng buộc nằm trong schema — đọc file schema là hiểu luật nghiệp vụ, không phải lần theo hàng chục if rải rác trong handler.
Ghi nhớ
- Pydantic v2 biến type hint thành hàng rào validation ở biên; field không có default là bắt buộc.
Field()mã hoá ràng buộc (gt,min_length,pattern,alias,description);descriptionchảy vào tài liệu OpenAPI.- Dùng
Decimalcho mọi giá trị tiền tệ —floatgây lệch sổ. KèmEnumchocurrency/kind,UUIDcho ID,EmailStrcho email. field_validatorcho luật một field (kèm chuẩn hoá),model_validatorcho luật liên field; némValueErrorlà tự thành 422.response_modellà hàng rào cuối chống lộ dữ liệu — cắt mọi field không khai báo, kể cả khi object gốc có.@computed_fieldcho giá trị dẫn xuất (vdavailable_balance) trong response.- Lỗi validation → HTTP 422 với
detailcóloc/type/msg, chỉ chính xác vị trí lỗi. - Tách schema request / response / DB model để chặn mass assignment và over-exposure cùng lúc.
pydantic-settingsđọc cấu hình từ env và validate — fail fast khi thiếu, không hardcode secret.
Bài viết liên quan
Vì sao Python là ngôn ngữ số một của data engineer: vai trò trong pipeline (ingest/transform/orchestrate), hệ sinh thái thư viện (pandas/polars/pyarrow/sqlalchemy), quản lý môi trường (venv/uv/poetry), và khi nào dùng Python vs SQL/Spark.
Định nghĩa hàm, tham số, *args/**kwargs, lambda, module/package, pip và virtualenv.
Lớp, kế thừa, đa hình, dunder methods, dataclass, type hints và nguyên tắc viết code sạch.
Exception handling, context manager (with), đọc/ghi file, JSON/CSV và logging đúng cách.