Salta al contenuto principale

03 - Pydantic v2: Validazione Avanzata, BaseModel, TypeAdapter e ConfigDict

Pydantic v2 misurato a 4,4x sulla v1, non 50x: model_validate, TypeAdapter, la differenza fra validator before e after, e ConfigDict con extra=forbid.

Cosa ne pensi di questo articolo?

03 - Pydantic v2: Validazione Avanzata, BaseModel, TypeAdapter e ConfigDict
FastAPIFoto:Mathews JumbaPexels

In Breve

Pydantic v2 misurato a 4,4x sulla v1, non 50x: model_validate, TypeAdapter, la differenza fra validator before e after, e ConfigDict con extra=forbid.

Pydantic v2 ha riscritto in Rust il motore di validazione, e su un modello di cinque campi questo vale 4,4 volte la velocita' della v1: 518.693 validazioni al secondo contro 118.497, misurate sulla macchina su cui e' stato scritto questo articolo. Il comando per rifare la misura e' in fondo, insieme a tutto cio' che serve per diffidarne.

La parte che cambia il codice ogni giorno, pero', non e' la velocita': sono i nomi nuovi (model_validate, model_dump, ConfigDict), il TypeAdapter che valida un tipo senza costruirgli un modello intorno, e i validator con mode="before" o mode="after", dove la differenza e' quale tipo ricevi.

Il codice di questo articolo e' stato eseguito

Tutti gli output e i messaggi d'errore riportati sotto vengono da un'esecuzione reale con Pydantic 2.13.4 su CPython 3.12.13, x86_64, il 2026-11-08. Non sono ricostruiti a memoria: dove vedi un ValidationError, quel testo e' stato copiato dallo stdout dell'interprete, compreso il codice di tipo fra parentesi quadre e il collegamento alla pagina dell'errore.

Il percorso

  • Che cosa dice e che cosa non dice il «core in Rust»
  • model_validate e model_dump: i nomi nuovi e il perche'
  • Field(): vincoli, alias, e come si leggono gli errori
  • field_validator e model_validator, con la prova di quale tipo arriva
  • TypeAdapter: validare una list[int] senza un BaseModel
  • ConfigDict, e la sola opzione che cambia la sicurezza della tua API
  • La migrazione da v1, riga per riga

Le prime cinque voci sono il lavoro quotidiano; la sesta e' quella che vale piu' del tempo che costa, perche' extra="forbid" trasforma un campo scritto male da «ignorato in silenzio» a «errore». L'ultima serve solo se hai codice da portare avanti, e in quel caso e' l'unica che conta.

Il core in Rust: che cosa significa, con un numero vero

In Pydantic v1 la validazione era Python: per ogni campo di ogni oggetto, chiamate di funzione interpretate. In v2 quel lavoro e' svolto da pydantic-core, una libreria compilata in Rust, e Python resta solo il linguaggio in cui dichiari il modello.

Il guadagno e' reale, ma il numero che circola - «da 5 a 50 volte» - non ha una fonte ufficiale. Le note di rilascio di FastAPI 0.100.0, che e' la versione che ha adottato Pydantic v2, parlano di «core riscritto in Rust» e di «prestazioni migliori», senza indicare un moltiplicatore, e la pagina sulle prestazioni della documentazione Pydantic da' consigli, non un fattore.

Quindi l'ho misurato, su un modello di cinque campi tipizzati, confrontando model_validate della v2 con il costruttore della v1 disponibile nel modulo di compatibilita' pydantic.v1:

Misura eseguita - 200.000 validazioni per ciascuna versione
v2 model_validate : 0.386 s per 200000 -> 518,693 val/s
v1 __init__       : 1.688 s per 200000 -> 118,497 val/s
rapporto v1/v2    : 4.4x

TypeAdapter list[100] : 0.570 s per 5000 -> 876,820 val/s
ciclo python list[100]: 0.832 s per 5000 -> 600,980 val/s
rapporto ciclo/TA     : 1.46x

Le prime tre righe dicono che su questo modello il fattore e' 4,4, non 50. Le ultime tre misurano una cosa diversa e utile: validare una lista di 100 oggetti con un TypeAdapter(list[Ordine]) e' 1,46 volte piu' rapido che ciclare in Python chiamando model_validate cento volte, perche' il ciclo avviene dentro Rust invece che nell'interprete.

Prendi questi numeri per quello che sono. Dipendono dal numero e dal tipo dei campi: un modello con validator personalizzati scritti in Python riporta il collo di bottiglia nell'interprete e il vantaggio si assottiglia, mentre un modello di soli tipi primitivi lo amplia. La v1 usata nel confronto e' quella del modulo di compatibilita' incluso in Pydantic 2.13.4, non un'installazione separata della 1.x.

La conseguenza pratica e' meno spettacolare del titolo e piu' utile: smetti di evitare la validazione per paura del costo. In un endpoint che fa una query al database, qualche microsecondo di validazione non si vede nel grafico dei tempi di risposta.

model_validate e model_dump

La v2 ha rinominato i metodi del modello con il prefisso model_. Non e' pedanteria: in v1 un campo del tuo modello chiamato json o dict entrava in collisione con i metodi omonimi. Il prefisso libera lo spazio dei nomi dei campi.

Costruire un modello dai dati che arrivano da fuori
from pydantic import BaseModel, Field, ConfigDict

class Cliente(BaseModel):
    # populate_by_name: accetta sia l'alias sia il nome Python
    model_config = ConfigDict(populate_by_name=True)
    codice_fiscale: str = Field(alias="codiceFiscale")

# Il JSON di un servizio esterno usa camelCase
Cliente.model_validate({"codiceFiscale": "RSSMRA80A01H501U"})
# codice_fiscale='RSSMRA80A01H501U'

# Grazie a populate_by_name passa anche il nome Python
Cliente.model_validate({"codice_fiscale": "RSSMRA80A01H501U"})
# codice_fiscale='RSSMRA80A01H501U'

# In uscita decidi tu quale forma usare
c = Cliente.model_validate({"codiceFiscale": "X"})
c.model_dump()                 # {'codice_fiscale': 'X'}
c.model_dump(by_alias=True)    # {'codiceFiscale': 'X'}

# Da stringa JSON, senza passare da json.loads
Cliente.model_validate_json('{"codiceFiscale": "X"}')

I quattro output nei commenti sono quelli stampati dall'esecuzione. La coppia da fissare e' model_dump() contro model_dump(by_alias=True): senza by_alias ottieni le chiavi Python, con by_alias le chiavi dell'alias. Se stai rispondendo a un client che si aspetta codiceFiscale e dimentichi by_alias=True, il client riceve una chiave che non conosce e non te lo dice nessuno.

populate_by_name=True e' la riga che rende il modello tollerante in ingresso: senza di essa, dichiarare un alias rende l'alias obbligatorio e {"codice_fiscale": ...} fallisce con «field required». E' il motivo per cui questa opzione compare in quasi tutti i modelli che fanno da ponte verso un'API esterna.

model_validate_json non e' solo una scorciatoia per json.loads seguito da model_validate: il parsing del JSON avviene dentro il core Rust, quindi il dizionario Python intermedio non viene mai costruito. Su payload grandi e' la differenza che si misura.

I vincoli, e come si legge un errore di Pydantic

Field() porta i vincoli accanto al tipo: gt, min_length, max_length, pattern. Ma la cosa che conviene imparare presto e' la forma del messaggio d'errore, perche' la leggerai molto.

Coercizione dei tipi, e i due errori che ne nascono
class Ordine(BaseModel):
    importo: float
    pezzi: int

Ordine.model_validate({"importo": "19.99", "pezzi": 3})
# importo=19.99 pezzi=3        <- la stringa "19.99" diventa float: accettato

Ordine.model_validate({"importo": "19.99", "pezzi": 3.5})
# 1 validation error for Ordine
# pezzi
#   Input should be a valid integer, got a number with a fractional part
#   [type=int_from_float, input_value=3.5, input_type=float]
#     For further information visit https://errors.pydantic.dev/2.13/v/int_from_float

Le due chiamate mostrano dove passa il confine della coercizione. La stringa "19.99" viene convertita in float senza proteste, perche' non si perde niente; il float 3.5 verso int viene rifiutato, perche' convertirlo butterebbe via la parte frazionaria. Un 3.0 al suo posto passerebbe.

Il messaggio ha quattro parti utili. La prima riga conta gli errori e nomina il modello; la seconda e' il percorso del campo; la terza spiega in inglese; la quarta, fra parentesi quadre, da' type=int_from_float, che e' il codice da usare se devi distinguere gli errori in modo programmatico - molto piu' affidabile che confrontare il testo del messaggio.

Il percorso del campo si annida: su un modello con una lista di oggetti, al posto di pezzi leggeresti qualcosa come righe.2.pezzi, cioe' il terzo elemento della lista. E' il motivo per cui conviene restituire al client exc.errors() e non solo il messaggio: la chiave loc contiene quel percorso in forma strutturata.

I validator, e la prova di quale tipo arriva

I decoratori sono due: field_validator per un campo singolo, model_validator per le regole che coinvolgono piu' campi. Il parametro che decide tutto e' mode, e il modo migliore per capirlo e' stampare quello che arriva.

mode="before" contro mode="after" - con l'output osservato
from pydantic import BaseModel, field_validator

class Pagamento(BaseModel):
    valuta: str
    importo: float

    @field_validator("valuta", mode="before")
    @classmethod
    def normalizza(cls, v):
        print(f"[before] ricevo {v!r} ({type(v).__name__})")
        return v.upper().strip() if isinstance(v, str) else v

    @field_validator("importo", mode="after")
    @classmethod
    def positivo(cls, v):
        print(f"[after]  ricevo {v!r} ({type(v).__name__})")
        if v <= 0:
            raise ValueError("l'importo deve essere positivo")
        return round(v, 2)

Pagamento.model_validate({"valuta": "  eur ", "importo": "19.999"})

# Output osservato:
# [before] ricevo '  eur ' (str)
# [after]  ricevo 19.999 (float)
# valuta='EUR' importo=20.0

Guarda i tipi stampati, perche' sono la risposta alla domanda. Il validator before riceve ' eur ', cioe' il dato grezzo come e' arrivato, spazi compresi: e' il posto giusto per pulire e normalizzare. Il validator after riceve 19.999 come float, non la stringa "19.999" che era nell'input: la conversione e' gia' avvenuta.

Da qui la regola. In before non puoi dare per scontato il tipo, quindi serve l'isinstance che vedi nel codice; in after il tipo e' garantito e scrivi la regola di dominio senza difese. Mettere la normalizzazione della valuta in after non funzionerebbe per il caso opposto: una stringa non convertibile fallirebbe prima di arrivare al tuo codice.

Nota anche che after puo' trasformare: round(v, 2) fa diventare 19.999 un 20.0, e il modello finale lo riporta. Un validator non e' solo un controllo, e' una funzione il cui valore di ritorno diventa il campo - dimenticare il return mette None nel campo, ed e' l'errore piu' comune con questi decoratori.

Il @classmethod sotto il decoratore non e' facoltativo: la funzione viene chiamata sulla classe, non su un'istanza, perche' l'istanza non esiste ancora. Ometterlo produce un errore in fase di definizione del modello, quindi almeno te ne accorgi subito.

model_validator: una regola che tocca piu' campi
from pydantic import BaseModel, model_validator

class Sconto(BaseModel):
    lordo: float
    percentuale: float = 0.0
    netto: float = 0.0

    @model_validator(mode="after")
    def calcola(self):
        self.netto = round(self.lordo * (1 - self.percentuale / 100), 2)
        return self

Sconto.model_validate({"lordo": 100.0, "percentuale": 22})
# lordo=100.0 percentuale=22.0 netto=78.0

La differenza di firma e' il punto: model_validator(mode="after") riceve self, l'istanza completa, e non un singolo valore. Per questo puo' calcolare netto a partire da lordo e percentuale, cosa che nessun field_validator potrebbe fare - un validator di campo non vede gli altri campi.

Il return self finale e' obbligatorio e facile da dimenticare: senza, il modello validato diventa None. E attenzione al fatto che questa versione muta l'istanza: funziona perche' il validator gira a validazione conclusa, ma se il modello e' configurato con frozen=True l'assegnazione solleva un errore, e il calcolo va fatto in un computed_field.

TypeAdapter: validare senza un modello

C'e' un caso che in v1 costringeva a scrivere un modello finto: validare qualcosa che non e' un oggetto. Una lista di interi, un dizionario, un'unione di tipi. TypeAdapter applica la validazione di Pydantic a qualunque annotazione di tipo Python.

Coercizione e fallimento su list[int] - output osservato
from pydantic import TypeAdapter, ValidationError

ta = TypeAdapter(list[int])

ta.validate_python([1, "2", 3.0])
# [1, 2, 3]        <- "2" e 3.0 convertiti, nessun errore

ta.validate_python([1, "due"])
# 1 validation error for list[int]
# 1
#   Input should be a valid integer, unable to parse string as an integer
#   [type=int_parsing, input_value='due', input_type=str]
#     For further information visit https://errors.pydantic.dev/2.13/v/int_parsing

Nella prima chiamata la lista eterogenea [1, "2", 3.0] diventa [1, 2, 3]: la stringa "2" e il float 3.0 sono convertibili senza perdita, quindi passano. Nella seconda, "due" non e' convertibile e il risultato e' un ValidationError.

La riga da notare nell'errore e' quella che contiene solo 1: e' il percorso del campo, e qui il percorso e' l'indice nella lista. Ti dice che l'elemento sbagliato e' il secondo, informazione che perderesti se validassi con un ciclo e un try/except per elemento.

Due avvertenze d'uso. TypeAdapter(...) compila uno schema, quindi va costruito una volta a livello di modulo e riusato: ricrearlo dentro una funzione chiamata spesso butta via il guadagno, che e' proprio quel 1,46x misurato sopra. E validate_python non e' validate_json: il primo si aspetta oggetti Python, il secondo una stringa o dei byte.

ConfigDict, e l'opzione che riguarda la sicurezza

La vecchia class Config interna e' sostituita da model_config = ConfigDict(...). Le opzioni sono molte; quelle che userai davvero sono poche.

Le opzioni che si usano, e la differenza fra ignore e forbid
from pydantic import BaseModel, ConfigDict

class RispostaAPI(BaseModel):
    model_config = ConfigDict(
        from_attributes=True,     # costruisci da oggetti ORM, non solo da dict
        populate_by_name=True,    # accetta alias E nome Python in ingresso
        use_enum_values=True,     # serializza gli enum col loro valore
        extra="forbid",           # "ignore" (default), "allow", "forbid"
        str_strip_whitespace=True # togli gli spazi ai bordi di ogni str
    )
    nome: str

# Con extra="forbid", un campo di troppo e' un errore:
RispostaAPI.model_validate({"nome": "a", "refuso": 1})
# 1 validation error for RispostaAPI
# refuso
#   Extra inputs are not permitted
#   [type=extra_forbidden, input_value=1, input_type=int]
#     For further information visit https://errors.pydantic.dev/2.13/v/extra_forbidden

L'output dell'errore e' quello osservato. La riga che cambia il comportamento della tua applicazione e' extra="forbid", perche' il default e' "ignore": con il default, un client che manda {"nmoe": "Mario"} invece di nome riceve un errore su nome mancante, ma se il campo fosse facoltativo riceverebbe 200 e il suo dato verrebbe scartato in silenzio.

Con forbid, lo stesso refuso diventa un errore esplicito che nomina il campo colpevole. Il compromesso e' la retrocompatibilita': se un client vecchio manda campi che non usi piu', forbid glieli rifiuta. La scelta ragionevole e' forbid sui modelli di ingresso delle tue API interne, ignore dove ricevi dati da terzi che non controlli.

from_attributes=True e' l'ex orm_mode di v1, e serve quando passi a model_validate un oggetto SQLAlchemy invece di un dizionario: senza di essa Pydantic non cerca gli attributi e fallisce. use_enum_values=True fa serializzare UserStatus.active come la stringa "active" invece dell'oggetto enum, che e' quasi sempre cio' che un client JSON si aspetta.

Migrare da v1: le sei righe che coprono quasi tutto

La migrazione e' noiosa ma meccanica. Questi sono i rinomini che incontrerai in ordine di frequenza.

Corrispondenze v1 → v2
# 1. serializzazione
user.dict()                          ->  user.model_dump()
user.json()                          ->  user.model_dump_json()

# 2. costruzione da dati esterni
User.parse_obj(data)                 ->  User.model_validate(data)
User.parse_raw(json_str)             ->  User.model_validate_json(json_str)

# 3. configurazione
class Config:                        ->  model_config = ConfigDict(
    orm_mode = True                  ->      from_attributes=True
                                     ->  )

# 4. validator
@validator("campo")                  ->  @field_validator("campo")
@root_validator                      ->  @model_validator(mode="after")

# 5. campi opzionali: in v2 Optional NON implica un default
nome: Optional[str]                  ->  nome: Optional[str] = None

# 6. vincoli
nome: str = Field(..., min_length=2) ->  nome: str = Field(min_length=2)

Le prime quattro voci sono sostituzioni testuali che puoi fare con fiducia. La quinta e' l'unica che cambia il comportamento e non il nome, ed e' quella che rompe il codice in silenzio: in v1 Optional[str] implicava None come default, in v2 dichiara solo che il campo puo' essere None e resta obbligatorio.

In pratica, dopo la migrazione un payload che prima passava inizia a rispondere «field required» su ogni campo opzionale a cui non avevi messo = None. E' il primo problema che incontrerai e la prima cosa da cercare. La sesta voce e' cosmetica: Field(...) con l'ellissi significava «obbligatorio» e ora e' implicito dall'assenza di un default.

Per il resto, la guida di migrazione ufficiale e' esaustiva e vale la pena leggerla una volta prima di cominciare, perche' elenca anche i casi limite che qui non stanno - i tipi constr, i campi generici, i modelli con riferimenti circolari che richiedono model_rebuild().

Che cosa sai fare adesso

Sai costruire modelli dai dati esterni con alias in entrambe le direzioni, leggere un ValidationError fino al codice di tipo, scegliere fra before e after sapendo quale tipo riceverai, validare un tipo nudo con TypeAdapter, e chiudere i tuoi modelli di ingresso con extra="forbid".

Il punto in cui questi modelli diventano la documentazione della tua API - perche' FastAPI genera lo schema OpenAPI da loro - e' in setup, type hints e OpenAPI. Il motivo per cui gli endpoint sono async, e cosa non va messo dentro una coroutine, e' in async e await. E se vuoi il quadro piu' ampio, la validazione al confine del sistema e' una difesa prima che una comodita': il tema e' trattato in input validation e sicurezza del backend.

Fonti

  • Pydantic - Validators - la semantica di mode="before" e mode="after", l'obbligo del @classmethod e il valore di ritorno che diventa il campo. Consultata il 2026-11-08.
  • Pydantic - API TypeAdapter - validate_python contro validate_json, e il costo di costruzione dell'adapter. Consultata il 2026-11-08.
  • Pydantic - Configuration - significato di extra, from_attributes, populate_by_name e use_enum_values. Consultata il 2026-11-08.
  • Pydantic - Migration Guide - la tabella delle corrispondenze v1 → v2, compreso il cambiamento su Optional senza default. Consultata il 2026-11-08.
  • Pydantic - Performance - i consigli ufficiali sulle prestazioni, che non contengono un moltiplicatore rispetto alla v1. Consultata il 2026-11-08.
  • Misure e output di questo articolo - Pydantic 2.13.4 su CPython 3.12.13, x86_64, 2026-11-08. Il confronto 4,4x viene da timeit con 200.000 iterazioni per versione su un modello di cinque campi, usando pydantic.v1 come riferimento v1; il rapporto 1,46x da 5.000 iterazioni su liste di 100 elementi. Tutti i ValidationError sono copiati dallo stdout dell'interprete.

Le prime cinque voci sono la documentazione del progetto, l'unica fonte che cambia insieme alla libreria: per qualunque opzione di ConfigDict non citata qui, e' li' che si guarda. L'ultima e' di natura diversa e vale solo per la macchina su cui e' stata presa - per questo riporta versioni e architettura. Se rifai la misura e ottieni 3x o 7x, il dato giusto e' il tuo.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti