Pydantic v2: The Rewrite in Rust

Pydantic v2 (červen 2023) je kompletní přepis: ověřovací jádro je nyní implementován v Rustu (pydantic-core), což je 5-50x rychlejší než v1 pro většinu případů použití. FastAPI 0.100+ používá Pydantic v2 nativně.

Co se naučíte

  • Rozdíly mezi Pydantic v1 a v2: Změnila se API, co migrovat
  • model_validate a model_dump: nové serializační API
  • Field(): omezení, alias, výchozí továrny
  • field_validator a model_validator: validátory s režimem
  • TypeAdapter: Ověření pro jiné typy než BaseModel
  • ConfigDict: Konfigurace modelu bez interní třídy Config
  • model_rebuild: dopředné reference a kruhové modely

Instalace a předpoklady

# Pydantic v2 (installato automaticamente con FastAPI recente)
pip install pydantic[email]  # Include EmailStr e validatori email
pip install pydantic-settings  # Per configurazione app

# Verifica versione
python -c "import pydantic; print(pydantic.VERSION)"
# 2.x.x

BaseModel: Základní struktura

Pydantické modely jsou třídy Pythonu, které z nich dědí BaseModel. Každé pole je atributem třídy s anotací typu. Pydantický automaticky vygeneruje metodu __init__, validace a serializace.

# Modello base con Pydantic v2
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from datetime import datetime
from enum import Enum

class UserStatus(str, Enum):
    active = "active"
    inactive = "inactive"
    banned = "banned"

class Address(BaseModel):
    street: str
    city: str
    country: str = "IT"  # Default value
    postal_code: Optional[str] = None

class User(BaseModel):
    # Field() fornisce metadata e vincoli
    id: int = Field(gt=0, description="User ID, must be positive")
    name: str = Field(
        min_length=2,
        max_length=100,
        description="Full name",
        examples=["Mario Rossi"],
    )
    email: EmailStr
    status: UserStatus = UserStatus.active
    address: Optional[Address] = None  # Modello annidato (nested model)

    # Field con default_factory: valore di default calcolato al momento della creazione
    tags: list[str] = Field(default_factory=list)
    created_at: datetime = Field(default_factory=datetime.now)

    # Alias: nome diverso nella serializzazione JSON
    internal_id: str = Field(alias="internalId", default="")

# Creazione con keyword arguments
user = User(
    id=1,
    name="Mario Rossi",
    email="mario@example.com",
    address=Address(street="Via Roma 1", city="Milano"),
    tags=["developer", "python"],
)

# v2: model_dump() sostituisce .dict()
user_dict = user.model_dump()
print(user_dict)
# {"id": 1, "name": "Mario Rossi", "email": "mario@example.com", ...}

# Con exclude e include
minimal = user.model_dump(include={"id", "name", "email"})
without_dates = user.model_dump(exclude={"created_at"})

# JSON string
user_json = user.model_dump_json()

model_validate: Vytváření modelů z externích dat

model_validate() je to způsob v2, ze kterého lze sestavit model slovník nebo libovolný objekt. Nahrazuje přímý konstruktor e .parse_obj() z v1.

# model_validate: parsing da diverse sorgenti
import json

# Da dizionario Python
data = {
    "id": 1,
    "name": "Mario Rossi",
    "email": "mario@example.com",
}
user = User.model_validate(data)

# Da JSON string (convenienza)
json_data = '{"id": 2, "name": "Luigi Bianchi", "email": "luigi@example.com"}'
user2 = User.model_validate_json(json_data)

# Con alias: se il JSON usa camelCase ma il modello usa snake_case
raw_data = {"internalId": "abc-123", "id": 3, "name": "Test User", "email": "test@example.com"}
user3 = User.model_validate(raw_data)
print(user3.internal_id)  # "abc-123" - letto dall'alias

# Validazione di dati da ORM (SQLAlchemy objects)
# Pydantic v2 puo leggere attributi da oggetti non-dict
class SQLAlchemyUser:
    def __init__(self):
        self.id = 1
        self.name = "ORM User"
        self.email = "orm@example.com"

orm_obj = SQLAlchemyUser()
user4 = User.model_validate(orm_obj, from_attributes=True)
# from_attributes=True: legge attributi invece che chiavi dict

Validátory: field_validator a model_validator

Pydantic v2 kompletně přepracoval validátory. Dva hlavní dekoratéři jsem @field_validator (pro jednotlivá pole) e @model_validator (pro mezipolní validace).

# Validators in Pydantic v2
from pydantic import BaseModel, field_validator, model_validator, ValidationError
from typing import Any

class PaymentOrder(BaseModel):
    amount: float
    currency: str
    discount_percent: float = 0.0
    final_amount: float = 0.0

    # field_validator: valida un singolo campo
    # mode="before": eseguito PRIMA della conversione di tipo
    # mode="after": eseguito DOPO (default in v2)
    @field_validator("currency", mode="before")
    @classmethod
    def normalize_currency(cls, v: Any) -> str:
        if isinstance(v, str):
            return v.upper().strip()  # "eur" -> "EUR"
        return v

    @field_validator("currency")
    @classmethod
    def validate_currency(cls, v: str) -> str:
        supported = {"EUR", "USD", "GBP", "JPY"}
        if v not in supported:
            raise ValueError(f"Currency {v} not supported. Use: {supported}")
        return v

    @field_validator("amount", "discount_percent")
    @classmethod
    def must_be_positive(cls, v: float) -> float:
        if v < 0:
            raise ValueError("Must be non-negative")
        return v

    # model_validator: accesso a tutti i campi dopo la validazione
    # mode="after": riceve il modello gia validato
    @model_validator(mode="after")
    def compute_final_amount(self) -> "PaymentOrder":
        discount = self.amount * (self.discount_percent / 100)
        self.final_amount = round(self.amount - discount, 2)
        return self

    # model_validator mode="before": riceve il dict grezzo
    @model_validator(mode="before")
    @classmethod
    def check_required_fields(cls, data: Any) -> Any:
        if isinstance(data, dict):
            if "amount" not in data:
                raise ValueError("amount is required")
        return data

# Test
try:
    order = PaymentOrder(amount=100.0, currency="eur", discount_percent=10.0)
    print(order.currency)      # "EUR" (normalizzato)
    print(order.final_amount)  # 90.0 (calcolato)
except ValidationError as e:
    print(e.errors())  # Lista strutturata degli errori

TypeAdapter: Ověření pro Non-BaseModel typy

TypeAdapter je jednou z nejužitečnějších nových funkcí v2: umožňuje používat ověření Pydantic na jakémkoli typu Pythonu bez vytváření a BaseModel oddaný.

# TypeAdapter: validazione di tipi primitivi e complessi
from pydantic import TypeAdapter
from typing import List, Dict, Union

# Validazione di una lista di int
int_list_adapter = TypeAdapter(List[int])
validated = int_list_adapter.validate_python([1, "2", 3.0])
print(validated)  # [1, 2, 3] - coercizione automatica

# Validazione di un tipo Union
NumberOrString = Union[int, str]
ns_adapter = TypeAdapter(NumberOrString)
print(ns_adapter.validate_python(42))    # 42
print(ns_adapter.validate_python("hello"))  # "hello"

# Validazione di dict complessi
UserDict = Dict[str, Union[int, str, List[str]]]
dict_adapter = TypeAdapter(UserDict)
result = dict_adapter.validate_python({
    "name": "Mario",
    "age": "30",  # Stringa che viene coerta a int? No: rimane str perche Union[int, str]
    "tags": ["dev", "python"],
})

# Uso pratico: validare config da variabili d'ambiente
from typing import Annotated
from pydantic import Field as PydanticField

PositiveInt = Annotated[int, PydanticField(gt=0)]
port_adapter = TypeAdapter(PositiveInt)

try:
    port = port_adapter.validate_python(int("8080"))  # 8080
    port = port_adapter.validate_python(-1)           # ValidationError!
except Exception as e:
    print(e)

# Serializzazione con TypeAdapter
data = [1, 2, 3]
json_str = int_list_adapter.dump_json(data)  # b'[1,2,3]'

ConfigDict: Konfigurace modelu

Ve verzi 2 se používá konfigurace šablony model_config = ConfigDict(...) místo třídy Config vnitřní v1.

# ConfigDict: tutte le opzioni principali
from pydantic import BaseModel, ConfigDict

class APIResponse(BaseModel):
    model_config = ConfigDict(
        # Permette la lettura da attributi ORM (SQLAlchemy, Django ORM)
        from_attributes=True,

        # Usa alias invece del nome Python nella serializzazione JSON
        populate_by_name=True,  # Permette anche il nome Python (non solo l'alias)

        # Serializzazione: converti enum al loro valore
        use_enum_values=True,

        # Validation: accetta campi extra senza errore (li ignora)
        extra="ignore",  # "allow", "ignore", "forbid"

        # JSON schema: titolo personalizzato
        title="API Response Model",

        # Validazione al momento dell'assegnazione (non solo alla creazione)
        validate_assignment=True,

        # Frozen: rende il modello immutabile dopo la creazione
        frozen=False,  # True = immutabile, genera __hash__

        # Stripping whitespace dagli str automaticamente
        str_strip_whitespace=True,

        # Serializzazione: esclude None per default
        # (utile per API che non vogliono campi null nel JSON)
        # Non disponibile come ConfigDict, usa model_dump(exclude_none=True)
    )

    user_id: int
    user_name: str  # str_strip_whitespace rimuove spazi iniziali/finali

# Esempio: modello con from_attributes per ORM
class OrmUser(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: int
    name: str
    email: str

# Compatibile con oggetti SQLAlchemy
class FakeOrmObject:
    id = 1
    name = "  Mario Rossi  "  # Con spazi
    email = "mario@example.com"

orm_obj = FakeOrmObject()
user = OrmUser.model_validate(orm_obj)
print(repr(user.name))  # "Mario Rossi" (spazi rimossi da str_strip_whitespace)

Migrace z Pydantic v1 na v2

Pokud máte existující kód s Pydantic v1, zde jsou nejčastější změny:

# PYDANTIC v1 -> v2: Cheat Sheet

# 1. .dict() -> .model_dump()
user.dict()          # v1
user.model_dump()    # v2

# 2. .json() -> .model_dump_json()
user.json()          # v1
user.model_dump_json()  # v2

# 3. .parse_obj() -> .model_validate()
User.parse_obj(data)    # v1
User.model_validate(data)  # v2

# 4. .parse_raw() -> .model_validate_json()
User.parse_raw(json_str)       # v1
User.model_validate_json(json_str)  # v2

# 5. class Config -> model_config = ConfigDict()
# v1:
class User(BaseModel):
    class Config:
        orm_mode = True

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

# 6. Validators: @validator -> @field_validator
# v1:
from pydantic import validator
class User(BaseModel):
    @validator("name")
    def name_must_not_be_empty(cls, v):
        return v.strip()

# v2:
from pydantic import field_validator
class User(BaseModel):
    @field_validator("name", mode="after")
    @classmethod
    def name_must_not_be_empty(cls, v: str) -> str:
        return v.strip()

# 7. @root_validator -> @model_validator
# v1:
from pydantic import root_validator
class Model(BaseModel):
    @root_validator
    def check_fields(cls, values):
        return values

# v2:
from pydantic import model_validator
class Model(BaseModel):
    @model_validator(mode="after")
    def check_fields(self) -> "Model":
        return self

Závěry

Pydantic v2 výrazně zrychlil ověřování dat v Pythonu a výraznější. Jádro Rust také zaručuje vynikající výkon pro intenzivní ověřování, přitom TypeAdapter řeší případ použití ověřovat typy bez vytváření vyhrazených modelů. Ve FastAPI má každý koncový bod výhody automaticky těchto optimalizací.

Připravované články ze série FastAPI

  • Článek 4: Dependency Injection ve FastAPI: Depends() pro čistý a testovatelný kód
  • Článek 5: Async Database s SQLAlchemy 2.0, AsyncSession a Alembic
  • Článek 6: Úkoly na pozadí: Od úloh na pozadí po celer a ARQ