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_validateemodel_dump: i nomi nuovi e il perche'Field(): vincoli, alias, e come si leggono gli errorifield_validatoremodel_validator, con la prova di quale tipo arrivaTypeAdapter: validare unalist[int]senza unBaseModelConfigDict, 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:
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.
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.
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.
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.
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.
# 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"emode="after", l'obbligo del@classmethode il valore di ritorno che diventa il campo. Consultata il 2026-11-08. -
Pydantic - API
TypeAdapter-validate_pythoncontrovalidate_json, e il costo di costruzione dell'adapter. Consultata il 2026-11-08. -
Pydantic - Configuration -
significato di
extra,from_attributes,populate_by_nameeuse_enum_values. Consultata il 2026-11-08. -
Pydantic - Migration Guide - la
tabella delle corrispondenze v1 → v2, compreso il cambiamento su
Optionalsenza 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
timeitcon 200.000 iterazioni per versione su un modello di cinque campi, usandopydantic.v1come riferimento v1; il rapporto 1,46x da 5.000 iterazioni su liste di 100 elementi. Tutti iValidationErrorsono 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.

Commenti
Caricamento commenti...
Accedi per commentare