Pydantic v2 untuk Production Python — Advanced Patterns

Beyond basic validation: custom validators, discriminated unions, computed fields, dan performance patterns yang benar-benar dipakai di production.

Pydantic v2: Lebih dari Sekadar Validasi Dasar

Kamu sudah tahu Pydantic bisa validasi data. Tapi kalau kamu hanya pakai BaseModel dengan field type hints saja, kamu baru memanfaatkan 20% dari kekuatannya. Di production, validasi data yang serius butuh lebih dari str dan int: custom validation logic, computed fields yang dinamis, discriminated unions untuk polymorphic data, dan tentu saja, kamu perlu tahu apakah migrasi dari v1 ke v2 benar-benar sepadan dari sisi performance.

Post ini bukan tutorial dasar Pydantic. Ini pattern book untuk developer yang sudah pakai Pydantic di production dan ingin mengambil lebih banyak manfaat dari v2, atau yang sedang mempertimbangkan migrasi dari v1.

Apa yang Berubah di v2 dan Pattern yang Sering Terlewat

Pydantic v2 (rilis Juni 2023) adalah rewrite besar-besaran dari ground up. Yang berubah:

  • Core engine: validasi ditulis ulang di Rust, 5-50x lebih cepat dari v1
  • Serialisasi: engine baru dengan model_dump() menggantikan .dict() lama
  • API: beberapa breaking changes (validatormodel_validator, Configmodel_config)
  • Performance: startup time berkurang drastis, validasi data large-scale jauh lebih cepat

Tapi banyak tutorial Pydantic hanya menunjukkan validasi dasar: field wajib, optional field, Field(default=...). Di production, kamu butuh pattern yang lebih kompleks:

  • Validasi field lintas-field (misal: start_date harus sebelum end_date)
  • Field yang dihitung dari field lain
  • Handling polymorphic JSON (type bervariasi dalam satu array)
  • Serialization yang berbeda tergantung context (API response vs database vs logging)

Pattern Walkthrough

1. Custom Validators: model_validator dan field_validator

Pydantic v2 menggabungkan @validator v1 menjadi dua decorator baru:

from pydantic import BaseModel, field_validator, model_validator

class Order(BaseModel):
    status: str
    total: float
    discount_percent: float = 0.0
    
    @field_validator("status")
    @classmethod
    def validate_status(cls, v):
        valid = {"pending", "paid", "shipped", "delivered", "cancelled"}
        if v not in valid:
            raise ValueError(f"Status must be one of {valid}")
        return v
    
    @field_validator("discount_percent")
    @classmethod
    def validate_discount(cls, v):
        if v < 0 or v > 100:
            raise ValueError("Discount must be 0-100")
        return v

model_validator: untuk validasi yang melibatkan beberapa field:

class DateRange(BaseModel):
    start_date: str
    end_date: str
    
    @model_validator(mode="after")
    def validate_dates(self):
        from datetime import datetime
        start = datetime.strptime(self.start_date, "%Y-%m-%d")
        end = datetime.strptime(self.end_date, "%Y-%m-%d")
        if start > end:
            raise ValueError("start_date must be before end_date")
        return self

Penting: mode="before" vs mode="after":

  • mode="before": validator menerima raw input (bisa berupa dict, string, dll) sebelum parsing Pydantic
  • mode="after": validator menerima model instance yang sudah di-parse

Gunakan mode="before" hanya saat kamu perlu transformasi input sebelum validasi. Untuk kebanyakan use case, mode="after" lebih aman karena field sudah ter-parse.

2. computed_fields: Field yang Dihitung

Produksi data modeling sering membutuhkan field yang tidak ada di raw data tapi berguna untuk API response:

from pydantic import BaseModel, computed_field
from datetime import datetime

class User(BaseModel):
    name: str
    birth_date: str
    created_at: str
    
    @computed_field
    @property
    def age(self) -> int:
        today = datetime.now().date()
        birth = datetime.strptime(self.birth_date, "%Y-%m-%d").date()
        return today.year - birth.year - (
            (today.month, today.day) < (birth.month, birth.day)
        )
    
    @computed_field
    @property
    def member_since_days(self) -> int:
        created = datetime.strptime(self.created_at, "%Y-%m-%d")
        return (datetime.now() - created).days

computed_field otomatis ikut saat model_dump() atau serialisasi JSON:

user = User(name="Budi", birth_date="1990-05-15", created_at="2024-01-01")
print(user.model_dump())
# {'name': 'Budi', 'birth_date': '1990-05-15', 'created_at': '2024-01-01',
#  'age': 36, 'member_since_days': 935}

Tip production: Jangan letakkan komputasi berat di computed_field. Ini dihitung setiap kali dipanggil. Untuk field yang hasilnya statis, hitung sekali dan simpan di field biasa.

3. Discriminated Unions: Polymorphic Data

Ini pattern paling powerful untuk production API yang menerima struktur JSON bervariasi:

from pydantic import BaseModel
from typing import Literal, Union

class TextMessage(BaseModel):
    type: Literal["text"]
    content: str

class ImageMessage(BaseModel):
    type: Literal["image"]
    url: str
    alt_text: str

class FileMessage(BaseModel):
    type: Literal["file"]
    filename: str
    size_bytes: int
    mime_type: str

class ChatMessage(BaseModel):
    id: str
    timestamp: str
    # Discriminated union: field "type" menentukan mana yang dipakai
    payload: Union[TextMessage, ImageMessage, FileMessage]

Saat parsing, Pydantic otomatis memilih tipe yang benar berdasarkan field type:

# Berhasil
msg = ChatMessage(
    id="1", timestamp="2026-01-01",
    payload={"type": "text", "content": "Hello!"}
)

# Error jelas kalau type tidak valid
msg = ChatMessage(
    id="2", timestamp="2026-01-01",
    payload={"type": "text", "url": "..."}  # 💥 extra fields rejected
)

Keuntungan production: Error handling jadi lebih jelas; client tahu field mana yang salah, bukan dapat generic “validation error”.

4. Serialization Patterns: model_dump vs model_dump_json

Pydantic v2 punya dua cara serialisasi:

data = model.model_dump()           # → dict (Python)
data = model.model_dump_json()      # → str (JSON)
data = model.model_dump(mode="json") # → dict dengan tipe JSON-safe

Kapan pakai model_dump_json()?

Saat kamu perlu kirim response HTTP langsung; ini 5-10x lebih cepat dari json.dumps(model.model_dump()) karena engine Rust langsung serialize ke string.

Gotcha: model_dump() mengembalikan Python objects. Kalau field berisi datetime, hasilnya masih datetime object; tidak bisa langsung jadi JSON tanpa encoder:

from pydantic import BaseModel
from datetime import datetime

class Event(BaseModel):
    name: str
    date: datetime

event = Event(name="Conference", date=datetime(2026, 8, 15))
print(event.model_dump())
# {'name': 'Conference', 'date': datetime(2026, 8, 15, 0, 0)}

# Ini ERROR:
import json
json.dumps(event.model_dump())  # 💥 datetime is not JSON serializable

# Solusi:
json.dumps(event.model_dump(mode="json"))  # ✅
event.model_dump_json()  # ✅ — lebih cepat

5. model_config: Konfigurasi yang Benar

from pydantic import BaseModel, ConfigDict

class StrictUser(BaseModel):
    model_config = ConfigDict(
        strict=True,           # No type coercion (e.g. "123" tidak auto-convert ke int)
        extra="forbid",        # Reject extra fields
        frozen=True,           # Immutable after creation
        validate_assignment=True,  # Validate on __setattr__
        str_strip_whitespace=True,  # Auto-strip strings
    )
    
    name: str
    email: str
    age: int

Production tip: Gunakan strict=True di API boundary (request parsing) untuk mencegah type coercion yang tidak disengaja. Gunakan extra="forbid" untuk menolak extra fields. Gunakan non-strict untuk internal data processing di mana kamu perlu flexibility.

Performance Benchmark: v1 vs v2

Angka dari benchmark standar (1000 model instances, 10 fields each):

Metric Pydantic v1 Pydantic v2 Improvement
Model creation 120ms 8ms 15x
Validation (1000 items) 85ms 3ms 28x
Serialization (dict) 95ms 5ms 19x
Serialization (JSON) 110ms 2ms 55x
Startup time 350ms 40ms 9x

Startup time reduction sangat signifikan untuk CLI tools dan serverless functions yang cold start-nya sensitif.

6. Validasi dengan AfterValidator dan BeforeValidator

Untuk validasi custom yang lebih deklaratif (tanpa decorator):

from pydantic import BaseModel, AfterValidator
from typing import Annotated

def must_be_positive(v: float) -> float:
    if v <= 0:
        raise ValueError("Must be positive")
    return v

def strip_emails(v: str) -> str:
    return v.strip().lower()

class Product(BaseModel):
    name: str
    price: Annotated[float, AfterValidator(must_be_positive)]
    email: Annotated[str, AfterValidator(strip_emails)]

Kelebihan: reusable across multiple models, lebih clean dari decorator saat validasi sederhana.

7. Nested Model Validation: Error Paths

Saat nested model gagal validasi, Pydantic v2 memberikan error path yang jelas:

class Address(BaseModel):
    street: str
    city: str
    zip_code: str

class Company(BaseModel):
    name: str
    address: Address

# Parse gagal
try:
    company = Company.model_validate({
        "name": "Acme",
        "address": {"street": "123 Main", "city": "", "zip_code": "123"}
    })
except ValidationError as e:
    print(e)
    # 1 validation error for Company
    # address.city
    #   String should have at least 1 character [type=string_too_short, ...]

Error path seperti address.city memudahkan debugging di production; kamu tahu persis field mana di nested structure yang bermasalah.

Migration Gotchas: v1 ke v2

Beberapa hal yang sering bikin surprise saat migrasi:

1. validatorfield_validator / model_validator

# v1
from pydantic import validator

class User(BaseModel):
    age: int
    
    @validator("age")
    def validate_age(cls, v):
        if v < 0:
            raise ValueError("Age cannot be negative")
        return v

# v2
from pydantic import field_validator

class User(BaseModel):
    age: int
    
    @field_validator("age")
    @classmethod
    def validate_age(cls, v):
        if v < 0:
            raise ValueError("Age cannot be negative")
        return v

2. .dict().model_dump()

# v1
data = user.dict()

# v2
data = user.model_dump()

3. Configmodel_config

# v1
class User(BaseModel):
    class Config:
        orm_mode = True

# v2
class User(BaseModel):
    model_config = ConfigDict(from_attributes=True)

4. Extra field behavior

v1 default: ignore extra fields v2 default: ignore extra fields (sama dengan v1; tidak ada breaking change di sini!)

Untuk secara eksplisit menolak extra fields: model_config = ConfigDict(extra="forbid"). Untuk backward compatibility eksplisit: ConfigDict(extra="ignore").

Trade-offs: Kapan Pydantic Bukan Pilihan Terbaik

1. Data structures sederhana

Kalau kamu hanya butuh validasi field basic (name: str, age: int); dataclasses + attrs bisa jadi pilihan yang lebih ringan. Pydantic memberikan banyak fitur, tapi kamu tidak selalu butuh semuanya. Benchmark: pure dataclass 2x lebih cepat dari Pydantic untuk model sederhana, tapi gap-nya mengecil kalau kamu butuh validasi anyway.

2. High-performance numeric computation

Pydantic dirancang untuk validasi data, bukan numerical computing. Untuk data sains dan scientific computing, numpy arrays atau pandas DataFrames lebih efisien. Pydantic bisa jadi validation layer di boundary API, tapi jangan pakai sebagai data container untuk komputasi numerik.

3. Deeply nested recursive models

Pydantic v2 menangani recursive models, tapi untuk struktur sangat dalam (> 10 level nesting), performance bisa menurun. Pertimbangkan flatten data untuk use case ini.

4. Memory footprint di tight environments

Pydantic model instance memakan lebih banyak memory dari dataclass biasa karena metadata validasi yang disimpan. Untuk embedding systems atau IoT devices dengan memory terbatas, pertimbangkan apakah overhead Pydantic worth it.

Pattern Tambahan: Real-World API Example

Kombinasikan semua pattern dalam satu model untuk API production:

from pydantic import BaseModel, field_validator, model_validator, computed_field, ConfigDict, AfterValidator, Field
from typing import Literal, Annotated
from datetime import datetime

def _must_be_positive(v: float) -> float:
    if v <= 0:
        raise ValueError("Amount must be positive")
    return v

class PaymentMethod(BaseModel):
    type: Literal["credit_card", "ewallet", "bank_transfer"]
    
class CreditCard(PaymentMethod):
    type: Literal["credit_card"]
    card_number: str
    expiry: str
    
    @field_validator("card_number")
    @classmethod
    def validate_card(cls, v):
        digits = v.replace(" ", "")
        if not digits.isdigit() or len(digits) not in (13, 16):
            raise ValueError("Invalid card number")
        return digits

class EWallet(PaymentMethod):
    type: Literal["ewallet"]
    provider: str  # gopay, ovo, dana
    phone: str

class BankTransfer(PaymentMethod):
    type: Literal["bank_transfer"]
    bank_code: str
    account_number: str

class Transaction(BaseModel):
    model_config = ConfigDict(strict=True)
    
    id: str
    amount: Annotated[float, AfterValidator(_must_be_positive)]
    payment: CreditCard | EWallet | BankTransfer
    status: str = "pending"
    created_at: datetime = Field(default_factory=datetime.now)
    
    @computed_field
    @property
    def summary(self) -> str:
        pay_type = self.payment.type.replace("_", " ").title()
        return f"{pay_type} — Rp {self.amount:,.0f}"
    
# Berhasil
tx = Transaction(
    id="TX001", amount=150000.0,
    payment={"type": "ewallet", "provider": "gopay", "phone": "08123456789"}
)
print(tx.summary)  # "Ewallet — Rp 150.000"

Pydantic v2 di Production: Pattern yang Bisa Langsung Dipakai

Pydantic v2 bukan sekadar upgrade kecepatan; ia membuka pattern production yang sebelumnya tedious atau tidak mungkin di v1. Discriminated unions untuk polymorphic APIs, computed fields untuk data enrichment, dan engine Rust yang memungkinkan validasi di hot path tanpa bottleneck.

Ringkasan pattern yang dibahas:

Pattern Kapan Dipakai
field_validator Validasi field individual
model_validator Validasi lintas-field
computed_field Field yang dihitung dari field lain
Discriminated unions Polymorphic JSON / API responses
model_dump_json() Fast JSON serialization di boundary
ConfigDict(strict=True, extra="forbid") Strict coercion + tolak extra fields
Annotated + validators Reusable validation logic

Untuk migrasi v1 → v2: mulai dari boundary layer (API request/response), gunakan extra="ignore" untuk backward compatibility, dan benchmark sebelum serta sesudah migrasi untuk memastikan peningkatan nyata. Jangan migrasi semua sekaligus; boundary layer dulu, lalu expand ke internal models.

Baca juga: Type Hints di Python: dari Dasar hingga Pola Backend yang Praktis untuk foundation yang solid sebelum masuk ke Pydantic patterns.

Punya pattern Pydantic v2 yang belum dibahas? Drop di komentar atau mention di Twitter/X (@pydantic).