Pydantic v2 rewrote its validation engine in Rust, and on a five-field model that is worth 4.4 times the speed of v1: 518,693 validations per second against 118,497, measured on the machine this article was written on. The command to repeat the measurement is at the bottom, together with everything you need to distrust it.
The part that changes your code every day, though, is not the speed: it is the new names
(model_validate, model_dump, ConfigDict), the
TypeAdapter that validates a type without building a model around it, and the
validators with mode="before" or mode="after", where the difference is
which type you receive.
The code in this article was executed
All the outputs and error messages reported below come from a real run with
Pydantic 2.13.4 on CPython 3.12.13, x86_64, on 2026-11-08.
They are not reconstructed from memory: wherever you see a ValidationError, that
text was copied from the interpreter's stdout, including the type code in square brackets and
the link to the error page.
The route
- What the "Rust core" does and does not say
model_validateandmodel_dump: the new names and whyField(): constraints, aliases, and how to read the errorsfield_validatorandmodel_validator, with proof of which type arrivesTypeAdapter: validating alist[int]without aBaseModelConfigDict, and the one option that changes the security of your API- The migration from v1, line by line
The first five entries are the daily work; the sixth is the one worth more than the time it
costs, because extra="forbid" turns a misspelled field from "silently ignored"
into "error". The last one is only for when you have code to carry forward, and in that case it
is the only one that matters.
The Rust core: what it means, with a real number
In Pydantic v1 validation was Python: for every field of every object, interpreted function
calls. In v2 that work is done by pydantic-core, a library compiled in Rust, and
Python remains only the language in which you declare the model.
The gain is real, but the number that circulates - "from 5 to 50 times" - has no official source. The release notes of FastAPI 0.100.0, the version that adopted Pydantic v2, talk about a "core rewritten in Rust" and "better performance", without giving a multiplier, and the performance page of the Pydantic documentation gives advice, not a factor.
So I measured it, on a model with five typed fields, comparing the v2 model_validate
with the v1 constructor available in the pydantic.v1 compatibility module:
v2 model_validate : 0.386 s per 200000 -> 518,693 val/s
v1 __init__ : 1.688 s per 200000 -> 118,497 val/s
ratio v1/v2 : 4.4x
TypeAdapter list[100] : 0.570 s per 5000 -> 876,820 val/s
python loop list[100] : 0.832 s per 5000 -> 600,980 val/s
ratio loop/TA : 1.46x
The first three lines say that on this model the factor is 4.4, not 50. The last three
measure something different and useful: validating a list of 100 objects with a
TypeAdapter(list[Order]) is 1.46 times faster than looping in Python and calling
model_validate a hundred times, because the loop happens inside Rust instead of in
the interpreter.
Take these numbers for what they are. They depend on the number and type of the fields: a model with custom validators written in Python moves the bottleneck back into the interpreter and the advantage shrinks, while a model of only primitive types widens it. The v1 used in the comparison is the one in the compatibility module shipped with Pydantic 2.13.4, not a separate installation of 1.x.
The practical consequence is less spectacular than the title and more useful: you stop avoiding validation out of fear of its cost. In an endpoint that runs a database query, a few microseconds of validation do not show up in the response-time chart.
model_validate and model_dump
v2 renamed the model's methods with the model_ prefix. It is not pedantry: in v1 a
field of your model called json or dict collided with the methods of
the same name. The prefix frees up the namespace of the fields.
from pydantic import BaseModel, Field, ConfigDict
class Customer(BaseModel):
# populate_by_name: accepts both the alias and the Python name
model_config = ConfigDict(populate_by_name=True)
tax_code: str = Field(alias="taxCode")
# The JSON of an external service uses camelCase
Customer.model_validate({"taxCode": "RSSMRA80A01H501U"})
# tax_code='RSSMRA80A01H501U'
# Thanks to populate_by_name the Python name passes too
Customer.model_validate({"tax_code": "RSSMRA80A01H501U"})
# tax_code='RSSMRA80A01H501U'
# On the way out you decide which form to use
c = Customer.model_validate({"taxCode": "X"})
c.model_dump() # {'tax_code': 'X'}
c.model_dump(by_alias=True) # {'taxCode': 'X'}
# From a JSON string, without going through json.loads
Customer.model_validate_json('{"taxCode": "X"}')
The four outputs in the comments are the ones printed by the run. The pair to fix in your mind is
model_dump() versus model_dump(by_alias=True): without
by_alias you get the Python keys, with by_alias the alias keys. If you
are answering a client that expects taxCode and you forget
by_alias=True, the client receives a key it does not know and nobody tells you.
populate_by_name=True is the line that makes the model tolerant on input: without
it, declaring an alias makes the alias mandatory and
{"tax_code": ...} fails with "field required". It is the reason why this option
appears in almost every model that acts as a bridge to an external API.
model_validate_json is not just a shortcut for json.loads followed by
model_validate: the JSON parsing happens inside the Rust core, so the intermediate
Python dictionary is never built. On large payloads that is the difference you can measure.
Constraints, and how to read a Pydantic error
Field() carries the constraints next to the type: gt,
min_length, max_length, pattern. But the thing worth
learning early is the shape of the error message, because you will read it a lot.
class Order(BaseModel):
amount: float
pieces: int
Order.model_validate({"amount": "19.99", "pieces": 3})
# amount=19.99 pieces=3 <- the string "19.99" becomes float: accepted
Order.model_validate({"amount": "19.99", "pieces": 3.5})
# 1 validation error for Order
# pieces
# 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
The two calls show where the boundary of coercion lies. The string "19.99" is
converted to float without complaint, because nothing is lost; the
float 3.5 towards int is rejected, because
converting it would throw away the fractional part. A 3.0 in its place would pass.
The message has four useful parts. The first line counts the errors and names the model; the
second is the path of the field; the third explains in plain English; the fourth, in square
brackets, gives type=int_from_float, which is the code to use if you need to tell
errors apart programmatically - far more reliable than comparing the text of the message.
The path of the field nests: on a model with a list of objects, in place of pieces
you would read something like lines.2.pieces, that is, the third element of the
list. It is the reason why you should return exc.errors() to the client and not just
the message: the loc key contains that path in structured form.
The validators, and the proof of which type arrives
There are two decorators: field_validator for a single field,
model_validator for the rules that involve several fields. The parameter that
decides everything is mode, and the best way to understand it is to print what
arrives.
mode="before" versus mode="after" - with the observed output
from pydantic import BaseModel, field_validator
class Payment(BaseModel):
currency: str
amount: float
@field_validator("currency", mode="before")
@classmethod
def normalize(cls, v):
print(f"[before] received {v!r} ({type(v).__name__})")
return v.upper().strip() if isinstance(v, str) else v
@field_validator("amount", mode="after")
@classmethod
def positive(cls, v):
print(f"[after] received {v!r} ({type(v).__name__})")
if v <= 0:
raise ValueError("the amount must be positive")
return round(v, 2)
Payment.model_validate({"currency": " eur ", "amount": "19.999"})
# Observed output:
# [before] received ' eur ' (str)
# [after] received 19.999 (float)
# currency='EUR' amount=20.0
Look at the printed types, because they are the answer to the question. The before
validator receives ' eur ', that is, the raw data as it arrived,
spaces included: it is the right place to clean and normalize. The after validator
receives 19.999 as a float, not the string "19.999" that
was in the input: the conversion has already happened.
From here comes the rule. In before you cannot take the type for granted, hence the
isinstance you see in the code; in after the type is guaranteed and you
write the domain rule without defences. Putting the currency normalization in after
would not work for the opposite case: a string that cannot be converted would fail before
reaching your code.
Note also that after can transform: round(v, 2) turns
19.999 into 20.0, and the final model reports it. A validator is not
only a check, it is a function whose return value becomes the field - forgetting the
return puts None in the field, and it is the most common mistake with
these decorators.
The @classmethod under the decorator is not optional: the function is called on the
class, not on an instance, because the instance does not exist yet. Omitting it produces an error
when the model is defined, so at least you notice right away.
model_validator: a rule that touches several fields
from pydantic import BaseModel, model_validator
class Discount(BaseModel):
gross: float
percentage: float = 0.0
net: float = 0.0
@model_validator(mode="after")
def compute(self):
self.net = round(self.gross * (1 - self.percentage / 100), 2)
return self
Discount.model_validate({"gross": 100.0, "percentage": 22})
# gross=100.0 percentage=22.0 net=78.0
The difference in signature is the point: model_validator(mode="after") receives
self, the complete instance, and not a single value. That is why it can compute
net from gross and percentage, something no
field_validator could do - a field validator does not see the other fields.
The final return self is mandatory and easy to forget: without it, the validated
model becomes None. And beware that this version mutates the instance: it
works because the validator runs once validation is finished, but if the model is configured with
frozen=True the assignment raises an error, and the calculation has to be done in a
computed_field.
TypeAdapter: validating without a model
There is a case that in v1 forced you to write a fake model: validating something that is not an
object. A list of integers, a dictionary, a union of types. TypeAdapter applies
Pydantic's validation to any Python type annotation.
list[int] - observed output
from pydantic import TypeAdapter, ValidationError
ta = TypeAdapter(list[int])
ta.validate_python([1, "2", 3.0])
# [1, 2, 3] <- "2" and 3.0 converted, no error
ta.validate_python([1, "two"])
# 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='two', input_type=str]
# For further information visit https://errors.pydantic.dev/2.13/v/int_parsing
In the first call the heterogeneous list [1, "2", 3.0] becomes
[1, 2, 3]: the string "2" and the float 3.0 are
convertible without loss, so they pass. In the second, "two" is not convertible and
the result is a ValidationError.
The line to notice in the error is the one containing only 1: it is the path of the
field, and here the path is the index in the list. It tells you that the wrong
element is the second one, information you would lose if you validated with a loop and a
try/except per element.
Two usage warnings. TypeAdapter(...) compiles a schema, so it must be built
once at module level and reused: recreating it inside a function that is called
often throws away the gain, which is precisely the 1.46x measured above. And
validate_python is not validate_json: the first expects Python objects,
the second a string or bytes.
ConfigDict, and the option that concerns security
The old inner class Config is replaced by model_config = ConfigDict(...).
There are many options; the ones you will really use are few.
ignore and forbid
from pydantic import BaseModel, ConfigDict
class ApiResponse(BaseModel):
model_config = ConfigDict(
from_attributes=True, # build from ORM objects, not only from dicts
populate_by_name=True, # accept alias AND Python name on input
use_enum_values=True, # serialize enums with their value
extra="forbid", # "ignore" (default), "allow", "forbid"
str_strip_whitespace=True # strip whitespace at the edges of every str
)
name: str
# With extra="forbid", an extra field is an error:
ApiResponse.model_validate({"name": "a", "typo": 1})
# 1 validation error for ApiResponse
# typo
# 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
The error output is the observed one. The line that changes the behaviour of your application is
extra="forbid", because the default is "ignore": with the default, a
client that sends {"nmae": "Mario"} instead of name gets an error about
the missing name, but if the field were optional it would get 200
and its data would be silently discarded.
With forbid, the same typo becomes an explicit error that names the guilty field.
The trade-off is backward compatibility: if an old client sends fields you no longer use,
forbid rejects them. The reasonable choice is forbid on the input
models of your internal APIs, ignore where you receive data from third parties you
do not control.
from_attributes=True is the former orm_mode of v1, and it is needed
when you pass an SQLAlchemy object to model_validate instead of a dictionary:
without it Pydantic does not look for attributes and fails. use_enum_values=True
makes UserStatus.active serialize as the string "active" instead of the
enum object, which is almost always what a JSON client expects.
Migrating from v1: the six lines that cover almost everything
The migration is boring but mechanical. These are the renames you will meet, in order of frequency.
# 1. serialization
user.dict() -> user.model_dump()
user.json() -> user.model_dump_json()
# 2. building from external data
User.parse_obj(data) -> User.model_validate(data)
User.parse_raw(json_str) -> User.model_validate_json(json_str)
# 3. configuration
class Config: -> model_config = ConfigDict(
orm_mode = True -> from_attributes=True
-> )
# 4. validators
@validator("field") -> @field_validator("field")
@root_validator -> @model_validator(mode="after")
# 5. optional fields: in v2 Optional does NOT imply a default
name: Optional[str] -> name: Optional[str] = None
# 6. constraints
name: str = Field(..., min_length=2) -> name: str = Field(min_length=2)
The first four entries are textual substitutions you can make with confidence. The fifth is the
only one that changes the behaviour and not the name, and it is the one that breaks code
silently: in v1 Optional[str] implied None as the default, in v2 it only
declares that the field may be None and it stays mandatory.
In practice, after the migration a payload that used to pass starts answering "field required" on
every optional field to which you had not added = None. It is the first problem you
will meet and the first thing to look for. The sixth entry is cosmetic: Field(...)
with the ellipsis meant "required" and is now implied by the absence of a default.
For the rest, the official migration guide is exhaustive and worth reading once before you start,
because it also lists the edge cases that do not fit here - the constr types, generic
fields, models with circular references that require model_rebuild().
What you can do now
You can build models from external data with aliases in both directions, read a
ValidationError down to the type code, choose between before and
after knowing which type you will receive, validate a bare type with
TypeAdapter, and close your input models with extra="forbid".
The point where these models become the documentation of your API - because FastAPI generates the
OpenAPI schema from them - is in
setup, type hints and OpenAPI.
The reason why endpoints are async, and what must not be put inside a coroutine, is in
async and await.
And if you want the broader picture, validation at the boundary of the system is a defence before
it is a convenience: the subject is covered in
input validation and backend
security.
Sources
-
Pydantic - Validators - the
semantics of
mode="before"andmode="after", the requirement of the@classmethodand the return value that becomes the field. Consulted on 2026-11-08. -
Pydantic - API
TypeAdapter-validate_pythonversusvalidate_json, and the construction cost of the adapter. Consulted on 2026-11-08. -
Pydantic - Configuration -
meaning of
extra,from_attributes,populate_by_nameanduse_enum_values. Consulted on 2026-11-08. -
Pydantic - Migration Guide - the
table of v1 → v2 correspondences, including the change on
Optionalwithout a default. Consulted on 2026-11-08. - Pydantic - Performance - the official performance advice, which does not contain a multiplier relative to v1. Consulted on 2026-11-08.
-
Measurements and outputs of this article - Pydantic 2.13.4 on CPython 3.12.13,
x86_64, 2026-11-08. The 4.4x comparison comes from
timeitwith 200,000 iterations per version on a five-field model, usingpydantic.v1as the v1 reference; the 1.46x ratio from 5,000 iterations on lists of 100 elements. All theValidationErrortexts are copied from the interpreter's stdout.
The first five entries are the project's documentation, the only source that changes together
with the library: for any ConfigDict option not cited here, that is where to look.
The last one is of a different nature and holds only for the machine on which it was taken - which
is why it reports versions and architecture. If you repeat the measurement and get 3x or 7x, the
right figure is yours.
Commenti
Caricamento commenti...
Sign in to comment