Pydantic v2: The Rewrite in Rust

Pydantic v2 (June 2023) is a complete rewrite: the validation core is now implemented in Rust (pydantic-core), making it 5-50x faster than v1 for most use cases. FastAPI 0.100+ uses Pydantic v2 natively.

What You Will Learn

  • Differences between Pydantic v1 and v2: APIs changed, what to migrate
  • model_validate and model_dump: the new serialization API
  • Field(): constraint, alias, default factories
  • field_validator and model_validator: validators with mode
  • TypeAdapter: Validation for non-BaseModel types
  • ConfigDict: Model configuration without internal Config class
  • model_rebuild: forward references and circular models

Installation and Prerequisites

# 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: The Base Structure

Pydantic models are Python classes that inherit from BaseModel. Each field is a class attribute with type annotation. Pydantic automatically generates the method __init__, validation and serialization.

# 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: Building Models from External Data

model_validate() it's the v2 way to build a model from a dictionary or arbitrary object. Replaces direct constructor e .parse_obj() of 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

Validators: field_validator and model_validator

Pydantic v2 has completely redesigned the validators. The two main decorators I am @field_validator (for individual fields) e @model_validator (for cross-field validations).

# 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: Validation for Non-BaseModel Types

TypeAdapter is one of the most useful new features of v2: allows you to use Pydantic validation on any Python type without creating a BaseModel dedicated.

# 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: Model Configuration

In v2, the template configuration uses model_config = ConfigDict(...) instead of class Config internal of 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)

Migration from Pydantic v1 to v2

If you have existing code with Pydantic v1, here are the most common changes:

# 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

Conclusions

Pydantic v2 has made Python data validation significantly faster and more expressive. The Rust core also guarantees excellent performance for intensive validation, while TypeAdapter solves the use case of validate types without creating dedicated models. In FastAPI, every endpoint benefits automatically of these optimizations.

Upcoming Articles in the FastAPI Series

  • Article 4: Dependency Injection in FastAPI: Depends() for Clean and Testable Code
  • Article 5: Async Database with SQLAlchemy 2.0, AsyncSession and Alembic
  • Article 6: Background Tasks: From BackgroundTasks to Celery and ARQ