Most of the Pydantic code I read treats the library as a type-hinting wrapper and stops there, which leaves the heavy lifting to Python. If your Pydantic performance is lagging, you are probably making Python do work that Rust was built to handle.
The core engine in Pydantic v2 is written in Rust, but you only get that speed if your models compile down to the internal schemas. Misuse the validators and your data pipeline slows by an order of magnitude. I have watched sites choke on large imports for exactly that reason: they upgraded and kept the legacy patterns.
1. Push constraints into Annotated types
Most devs reach for @field_validator because it feels familiar. The catch is that those validators run in Python, after the core validation logic, and that is where the bottleneck comes from. Annotated types avoid it, because Pydantic compiles them into its Rust-based schema.
The slow version looks like this:
class UserNaive(BaseModel):
id: int
@field_validator("id")
def bbioon_check_id(cls, v):
if v < 1:
raise ValueError("ID must be positive")
return v
And here it is again, rewritten for Pydantic performance:
from typing import Annotated
from pydantic import BaseModel, Field
class UserOptimized(BaseModel):
id: Annotated[int, Field(ge=1)]
Field constraints keep the check inside pydantic-core. On large datasets, benchmarks put that at up to 30x faster.
2. Parse JSON with model_validate_json
Calling json.loads() on a JSON string and handing the result to model_validate() makes Python build a full intermediate dictionary first. The built-in JSON parser does the whole thing in one pipeline instead. Fewer memory allocations is where a lot of the Pydantic performance win comes from.
# The slow way
data = json.loads(raw_json)
model = User.model_validate(data)
# The fast way
model = User.model_validate_json(raw_json)
If you are debugging a messier Python environment, I wrote about Py-Spy profiling separately.
3. Validate lists with TypeAdapter
Give a dev a list of objects and they write a loop, and Python loops are slow. The other common workaround, a wrapper model that exists only to hold the list, just adds overhead. TypeAdapter was built for this case. It validates the whole batch without leaving the Rust side.
from pydantic import TypeAdapter
user_adapter = TypeAdapter(list[User])
users = user_adapter.validate_python(large_batch_list)
4. Leave from_attributes off for plain dictionaries
from_attributes=True earns its keep with an ORM like SQLAlchemy. When the input is always a dictionary, all it buys you is a layer of getattr() calls you never needed. Leave it at the default of False and you get the faster dictionary lookups.
I keep finding legacy code where somebody switched this on just in case and never went back. Turning it off can shave hundreds of milliseconds off a large batch run. If the slowness is spread across your stack rather than sitting in one model, read the execution profile first and fix slow Python code from there.
If this kind of tuning is eating your dev hours, I can take it off your plate. I have been wrestling with WordPress and backend Python integrations since the 4.x days.
The performance-first refactor
None of this is really micro-optimization. It is using the library the way it was designed to be used. Moving the logic into declarative schemas also makes the models easier to maintain later, which for me is the bigger payoff.
For the official details, see the Pydantic performance documentation and PEP 593 on Annotated types.