Performance and pydantic-core

Why v2 is fast, what validation actually costs, and the three habits that account for most of the difference.

Overview

Two libraries in a trench coat

People ask whether Pydantic is fast enough. In version 2 the answer is usually yes, because the validation engine is written in Rust. That engine is a separate library called pydantic-core, and the Python library you import sits on top of it. So there are two libraries working together.

Knowing that split helps you reason about speed. The Rust part is quick at the checks it knows how to do. The Python part, including any validator you write, runs at Python speed. So the time goes where your own code runs, not where the library does.

This article starts with how the two libraries fit together. It shows where the time actually goes when you validate data, which is often not where people expect. It gives three habits that keep things quick, such as parsing JSON directly and building adapters once. It then looks at validators, which are the expensive part, and how to keep them cheap. You do not need to optimise early, but you should know which choices cost something. That lets you make sensible decisions without measuring everything first.

Installing pydantic installs two things.

A Python layer that reads your class, reads the annotations, resolves types. Builds a schema showing how to validate the model. This runs once, when the class is defined.

**pydantic-core**, a compiled Rust engine that runs that schema against data. This runs each time you validate.

Almost all about v2's speed follows from that split. check is compiled code walking a prepared schema, not Python reading annotations per call. Version 1 did the latter. This Is why v2 was a rewrite rather than an tuning.

It also shows details you'll have noticed. Error type codes look like machine identifiers because they come from the core. model_validate_json beats parsing apart because the core parses and validates in one pass. And there's a compiled wheel per platform. There's compiled code in there.

Worth knowing

Pydantic v2 is two pieces: a Python layer that reads your annotations and builds a schema, and pydantic-core, a Rust engine that executes it.
Cost scales with how much converting is needed. A value already of the right type is checked and passed through cheaply.
Validate once, at the boundary. Re-validating an object that has already passed produces the same result for the full price.
Validate a collection in one call — TypeAdapter(List[X]) — so the loop happens inside the core rather than in Python.
Every custom validator is a call back out into Python. A Field constraint doing the same job stays in Rust.
These timings run on WebAssembly, several times slower than a native interpreter. The ratios transfer; the seconds do not.

Performance and pydantic-core: What Validation Actually Costs

Why v2 is fast. Here The time goes. The three habits that account for most of the gap.

Already-correct types are nearly free

Validation cost depends on how much converting there is to do. A value that is already right is checked and passed through.

example_01.pyPydantic
Output

Validate once, not at every layer

Re-validating something that has already passed is the most expensive no-op available. The result is identical.

example_02.pyPydantic
Output

Let the core do the looping

One call into Rust for a whole list beats a Python loop making one call per item. Same result, less crossing back and forth.

example_03.pyPydantic
Output

Validators are Python in a Rust pipeline

Every custom validator is a call back out of the compiled core. A constraint doing the same job stays inside it.

example_04.pyPydantic
Output

Build schemas once

Constructing a model class or a TypeAdapter compiles a schema. Doing it inside a loop repeats that work every time.

example_05.pyPydantic
Output

Parsing JSON directly

The last of the three habits: hand the text over instead of building an intermediate dict for it.

example_06.pyPydantic
Output

Where the time actually goes

Three parts, worth separating.

Schema building happens once per class or adapter. It's the most costly single step and it should be invisible. Unless you build schemas again and again. This Is the mistake below.

check happens per value. Cost scales with how much work there's. A value already of the right type is checked and passed through. One needing conversion costs more. One needing a custom checker costs the most. That means leaving Rust for Python.

output is often cheaper than check, and follows the same shape.

The three habits

Almost all avoidable cost comes down to three things.

### Validate once

The rule from the first module, restated as a speed point. Re-checking an object that has already passed makes an same result for the full price.

It happens more than people expect. A model checked in a request handler, passed to a service that validates it again, handed to a store that makes its own model from it. Each layer is defensively re-checking data that can't have changed.

The discipline is to decide where the boundary is and trust all past it. If a function's arg is a model, it has been checked. Checking again buys nothing.

The exception is truly mutable data. If validate_assignment is off and a thing has been assigning freely, the object may no longer match its annotations. But the fix there's to turn on the setting or freeze the model, not to re-validate.

### Validate collections in one call

[Model.model_validate(r) for r in rows]              # per item
TypeAdapter(List[Model]).validate_python(rows)       # whole list

same results. The first crosses between Python and Rust once per row. The second makes one call and loops inside the core.

For bulk work — a file import, a batch job, a large API response — this is the single most good change on hand, and it's one line.

### Parse JSON directly

model_validate_json(raw) rather than model_validate(json.loads(raw)).

The two-step form builds a full mid structure of Python objects, then converts it again. The direct form reads the text and makes the final values in one pass.

As the parsing module covered, it's also better for errors and for decimal precision. Three gains for less code.

Validators are the expensive part

A field_validator is Python. each time it runs, the core suspends, calls into the interpreter, and resumes.

For a model built a few times per request that is irrelevant. For a hundred thousand rows it's the dominant cost, and it's worth two questions.

Could this be a constraint? Field(gt=0) and a checker checking v > 0 reject the same values. The constraint runs in Rust. It also reaches the schema. This Is the more key reason.

Is it doing work that could be done once? A checker that rebuilds a set of permitted values on each call is doing that per item. Hoisting it to module level is often a bigger win than anything else in the model.

Building schemas repeatedly

The mistake that looks like nothing in review:

for row in rows:
    TypeAdapter(List[int]).validate_python(row)     # recompiles every iteration

Adapters belong at module level. The same applies to any pattern that defines a model class inside a function called again and again. Each call builds a new class and a new schema.

Where the type is only known at runtime, cache the adapters in a dict keyed by type, or wrap a factory in functools.lru_cache.

Keeping perspective

Two things worth saying plainly.

check is often not your bottleneck. A model checking in single-digit microseconds sits next to a DB query taking milliseconds and a network call taking hundreds. In a common request handler, check is a rounding error. Choosing a dataclass "for speed" in a handler that then makes three SQL queries is optimising the wrong end.

Measure before changing anything. The habits above are free. Adopt them because they're also clearer. Anything beyond them should follow a profile, not an intuition.

The place check truly dominates is bulk: large sets, file imports, high-throughput pipelines. That is where the one-call-per-set rule earns real time.

A note on these timings

each reading on this page runs on CPython compiled to WebAssembly, a few times slower than a native interpreter, on one core.

The ratios transfer — checking a list in one call really is faster than looping, by roughly the factor shown. The abs seconds don't. Don't quote them as figures for a server.

That caveat applies to any benchmark run anywhere, including ones you write yourself on your laptop. scaled checks under same rules are helpful. Abs numbers are a property of the machine.

Performance and pydantic-core: summary

Pydantic v2 is a Python layer that builds schemas and a Rust core that runs them. This Is why check is cheap enough to do on each request.

Three habits account for most of the avoidable cost. Validate once at the boundary, validate sets in a single call. Parse JSON right. Prefer constraints over checkers where either would do. Build schemas once rather than in a loop.

Then stop, because check is rarely the slow part. The left questions belong to a profiler rather than to a rule of thumb.

What not to optimise

A short list of things that look like speed choices and aren't.

Choosing a dataclass over a model in a request handler. The handler then makes three DB queries. The check was never the cost.

Skipping check on data from your own DB. It's cheap on already-correct types. The promise is worth more than the microseconds. If a column can be null and the model says it can't, you want to know.

Avoiding nested models to reduce check count. A flat model with the same fields does the same total work. Nesting is an organisational choice, not a speed one.

**Reaching for model_construct.** It skips check fully. This Makes it fast and unsafe. It exists for cases where the data provably came from a trusted source. Rebuilding from your own cache, say. Using it to speed up a normal path drops the property you installed the library for.

Measuring properly

If you do need to measure, three things make the result real.

Warm up. The first build of a model builds its schema. Timing that alongside the validations makes the first run look terrible and tells you nothing about the steady state.

Time the right thing. Wrap the check, not the loop that also builds the input data. Making ten thousand dicts isn't free either.

Compare under same rules. Same machine, same process, same interpreter. abs numbers don't live a move between any of those. This Is why the timings on this page are presented as ratios.

time.perf_counter is enough for A-versus-B. For finding where time goes in a real use, a profiler will point at the DB long before it points at check.

Mistakes people make with Performance and pydantic-core

Re-checking what has already passed. The most common and most costly no-op. If a function's arg is a model, it was checked. Checking again makes an same result for the full price.

Looping in Python over a set. [Model.model_validate(r) for r in rows] crosses into Rust once per row. One call with a TypeAdapter(List[Model]) does the loop inside the core.

Building schemas again and again. A TypeAdapter made inside a loop, or a model class defined inside a function called per request, recompiles a schema each time. Module level, once.

Doing work inside a checker that could be done outside. Rebuilding a set of permitted values on each call does it per item. Hoisting it's often a bigger win than anything else in the model.

**Using model_construct to go faster.** It skips check fully. That is correct for rebuilding from a provably trusted source and a way of quietly dropping the promise all over else.

Optimising check before profiling. In a handler that makes three DB queries, check is a rounding error. A profiler will point at the DB long before it points here.

Quoting benchmark seconds. abs numbers are a property of the machine. Doubly so on this page. This Runs on WebAssembly. Ratios under same rules transfer. Seconds don't.

Why it was worth rewriting

It's worth understanding what the v2 rewrite really bought. It shows why this module is short.

In v1, check was Python reading annotations on each call. That put Pydantic on the key path of a lot of uses in a way that showed up in profiles. Made "is check too slow?" a fair question to ask routinely.

Moving run into Rust changed the answer from "at times" to "almost never". A model that validates in a few microseconds doesn't compete with anything else in a request.

So the hands-on guidance became much simpler: adopt the three habits because they're also clearer code. Else stop thinking about it. That is a better place to be than a set of tuning tricks. It's the reason most of this module is about what not to optimise.

The honest summary

Most uses shouldn't think about this module at all.

Adopt the three habits — validate once, validate sets in one call, parse JSON right — because each is also clearer code than the option. Then stop. They aren't speed tricks. They're the obvious way to write it. This Happens also to be the fast way.

If a thing is truly slow, profile it. The answer will often be I/O. On the occasions it really is check the answer will often be one of the three habits not being followed, or a checker doing per-item work that belongs outside the loop.

What changed in v2 is that this stopped being a live concern for plain code. check used to be a thing you budgeted for. Now it's a thing you can put at each boundary without thinking about the cost. That is a better outcome than any tuning advice.

Performance and pydantic-core in one line

Validate once at the boundary, validate sets in a single call. Hand JSON straight to Pydantic. Then stop thinking about it. In v2 check is almost never the slow part.

Questions people ask about Performance and pydantic-core

Why is Pydantic v2 faster than v1? Validation and serialisation moved from Python to pydantic-core, a Rust library. The model's rules are compiled into a schema once and executed in native code.

Do I need to install Rust? No. Wheels for common platforms ship on PyPI with the compiled core already built.

What is the cheapest speedup? Use model_validate_json for JSON input, reuse TypeAdapter objects, and prefer built-in constraints over Python validators.

Why is building a TypeAdapter in a loop slow? Each construction builds a new core schema. Create it once at module level and call validate_python on it.

Are Python validators slow? Each call crosses from Rust into Python, which is much slower than a built-in constraint. Use Field(ge=0) where it can express the rule.

When should I stop optimising? When the profile shows validation is a small share of request time. For most services, database and network calls dominate.

A closing thought

The most handy speed property of Pydantic v2 isn't that it's fast. It's that it's fast enough to stop being a consideration.

That changes how you write code. check at each boundary stops being a trade-off and turns the default. This Means more of your program can assume its inputs are correct. And that is worth a lot more than the microseconds.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What are the two pieces of Pydantic v2?

  2. Which is faster for validating 4,000 rows?

  3. Why is a `field_validator` more expensive than an equivalent `Field` constraint?

  4. How should the timings on this page be read?

Cheat sheet

Performance and pydantic-core

See why Pydantic v2 validates at Rust speed, where the time goes, and which three habits make the biggest performance difference.

PYDANTIC · vizlearn.in/pydantic/performance_and_pydantic_core.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.