Performance Optimization for Pydantic Models in FastAPI
Pydantic v2 is fast enough that most services never need this guide. When a service does need it, the problem is almost never that validation is slow — it is that the same validation is happening more times than anyone intended, or that a configuration flag switched on for one model is now being paid on every request.
This guide is part of Advanced Pydantic Validation and Serialization. It covers where model time actually goes and how to triage it. The specific techniques — choosing a serialization call, adapters for non-model types, and skipping validation on trusted data — each have a page of their own beneath this one.
Prerequisites
You need a way to measure. Optimizing Pydantic without a profile is guessing, and the guesses are reliably wrong — the operation people assume dominates is rarely the one that does. A request-level profile of a representative endpoint, with a realistic payload, is the entry requirement for everything below.
You should also understand what validators run when, and how nested serialization traverses a graph, because those are the two directions all of this work happens in. If you are still on Pydantic v1, stop here: the migration is worth more than every optimization on this page combined.
Core Mechanics: What Costs Anything
Two operations dominate: turning untyped input into a model, and turning a model into output. Both are compiled and both are fast per call. That "per call" is the whole subject — the interesting question is never how long one validation takes, it is how many times it happens.
Three facts about the compiled core explain most of what follows.
Schema compilation is a one-time, class-definition cost. Building a model's validator is expensive relative to running it. For BaseModel subclasses this is paid at import and never again. It becomes a live problem only when something builds validators repeatedly at runtime, which is the trap covered in TypeAdapter for Non-Model Types in Pydantic.
An instance of the right type is not re-validated. When a value already is an instance of the declared model, Pydantic accepts it rather than re-running its validators. Composing models is therefore cheap by default, and re-validation is something you have to ask for.
Serialization cost tracks the number of nodes. Every node in the graph is one rendering decision, so response cost is governed by how much you return.
Measuring what configuration costs you
The most surprising line item in a slow service is usually a model_config flag. These are correctness features with running costs, and because they are declared once and apply everywhere, the cost is easy to lose track of. Counting validator invocations makes it concrete — the example below counts calls rather than timing them, so the numbers are exact rather than machine-dependent:
class Parent(BaseModel):
child: Child
class RevalidatingParent(BaseModel):
"""revalidate_instances declared HERE, on the outer model. A common guess."""
model_config = ConfigDict(revalidate_instances="always")
child: Child
class RevalidatedChild(BaseModel):
"""revalidate_instances declared on the model that is itself revalidated."""
model_config = ConfigDict(revalidate_instances="always")
size: int
The recorded output:
$ GET /nested-instance
200 OK
{
"validations_building_child": 1,
"extra_validations_default_parent": 0,
"extra_validations_config_on_outer_model": 0,
"extra_validations_config_on_inner_model": 1
}
$ GET /assignment
200 OK
{
"validations_on_build": 1,
"validations_added_by_two_assignments": 2
}
$ GET /defaults
200 OK
{
"default_field_omitted_without_validate_default": 0,
"default_field_omitted_with_validate_default": 1
}
Read the first block carefully, because it contains a genuine trap. Placing an already-validated Child into an ordinary Parent costs nothing extra — that is the instance short-circuit. Setting revalidate_instances="always" on the outer model also costs nothing extra, and that is not a saving: it is the setting failing to do anything. The flag belongs on the model being revalidated, and only when it is declared on RevalidatedChild does the extra validation actually happen. If you enabled that flag for a correctness reason and put it on the container, you are getting neither the safety nor the bill.
The other two blocks are simpler and worth knowing as ratios rather than absolutes. validate_assignment costs one field validation per attribute write against a baseline of zero, so it is inexpensive for an object written once at startup and expensive for one mutated in a loop. validate_default runs your validators over defaults that the caller never supplied, which is the correct behaviour when a default is computed or needs normalising, and pure waste when it is a literal.
None of these flags is wrong. The point is that each is a decision with a running cost, and they are frequently enabled globally to solve one model's problem.
Production Implementation: Triage in Order of Work Removed
Optimizations are worth doing in order of how much work they delete, not how clever they are.
First: stop doing the same validation twice
The single largest recoverable cost in most services is data being validated more than once as it moves between layers. The shapes this takes are predictable. A handler validates the request body, then a service function re-parses the same data into its own model. A repository returns rows, a mapper builds models, and a second mapper builds nearly identical models for the response. Or — the most common one — a handler calls model_dump() and returns a dictionary, so FastAPI has to validate it back into the declared response_model before it can serialize it.
The fix is architectural rather than technical: validate untrusted input once where it arrives, then pass the resulting object rather than its data. It costs nothing to implement and it removes whole traversals.
Second: return fewer nodes
Once duplicate work is gone, response size is the next lever, and it is usually larger than any change to how you serialize. A narrower response model, a paginated nested collection, or a summary count in place of an embedded list all reduce the node count, and node count is what serialization time tracks. The measurement approach is in Handling Deeply Nested JSON Models Efficiently.
Third: choose the right call for the job
Only after the first two is it worth looking at how you serialize. There are several ways to turn a model into a response body and they do not cost the same, largely because some of them build an intermediate Python structure that is immediately discarded. Which to use, and the measured ranking between them, is in Pydantic Model Serialization Performance in FastAPI.
Similarly, data that is not a model — a list of integers from a queue, a dictionary from a cache, a union parsed from a webhook — should go through a TypeAdapter held at module level rather than a wrapper model invented to hold it. The reasoning, and the one mistake that makes adapters slow instead of fast, are in TypeAdapter for Non-Model Types in Pydantic.
Last, and rarely: skip validation entirely
model_construct() builds a model without validating anything. It is the largest single saving available and also the only technique here that can make your service incorrect, because it will happily construct an object whose contents violate every rule its type claims to enforce. It is appropriate for data your own code produced and has already checked, and inappropriate for anything that crossed a boundary — including cache entries, which are not trusted data merely because you wrote them. Read model_construct and When to Skip Validation in Pydantic before using it, since that page is largely about the failure modes.
How to actually get the numbers
"Profile first" is easy advice and annoyingly vague, so here is what it means concretely for Pydantic work.
Start by separating model time from everything else, because the usual outcome of a first profile is discovering that Pydantic is not your problem at all. Wrap a representative request in cProfile and sort by cumulative time:
import cProfile, pstats
profiler = cProfile.Profile()
profiler.enable()
client.post("/orders/", json=LARGE_ORDER)
profiler.disable()
pstats.Stats(profiler).sort_stats("cumulative").print_stats(25)
Two frame names are the ones to look for. Validation appears under pydantic_core._pydantic_core.ValidatorInstance.validate_python or validate_json; serialization appears under the corresponding serializer frames. Because the work happens inside compiled code, you get one frame for the whole traversal rather than a breakdown per field — which is exactly why a profile tells you that validation is expensive and never why.
The "why" comes from counting, not timing. Instrument a validator on the model you suspect, exercise the endpoint once, and read the counter:
CALLS = 0
class Order(BaseModel):
id: int
@field_validator("id")
@classmethod
def _count(cls, v: int) -> int:
global CALLS
CALLS += 1
return v
If a single request produces two validations of the same model, you have found duplicated work, and no amount of timing analysis would have shown you that as clearly as the number 2. This is the technique the configuration measurements above use, and it is the one worth reaching for whenever a profile says "validation is slow" — because most of the time the honest answer is that validation is fine and it is happening twice.
Finally, profile with a payload that resembles production. A model that is inexpensive on a three-field fixture and ruinous on a real order with two hundred line items is the normal case, and a benchmark built from the fixture will tell you there is no problem.
What not to do
Do not remove response_model to make an endpoint faster. It is what documents the endpoint and what stops internal fields reaching clients, and returning the declared model does not incur an extra validation anyway. Do not disable validation on inbound request bodies; that is the one traversal you genuinely need. And do not micro-optimize field types before you have a profile, because the result is a less readable model and an unchanged latency graph.
Async and Concurrency Notes
Validation and serialization are synchronous CPU work executed on the event loop thread. While a handler renders a large response, that worker is not accepting anything else — so a single heavyweight endpoint raises latency for every other endpoint sharing the process, and the symptom appears as unrelated requests getting slower.
This changes what a fix looks like. If one endpoint dominates CPU, the effective interventions are reducing what it returns, caching its output, or moving the work off the request path entirely — not tuning the serializer. Genuinely heavy transformations belong in a thread or process pool, or in a background job; see Async Correctness and Concurrency for how offloading interacts with the event loop, and Caching Strategies for the case where the same expensive response is produced repeatedly.
Testing Strategy
Performance assertions on wall-clock time in CI are flaky and get disabled. Assert on work instead, which is deterministic.
def test_request_validates_the_body_exactly_once(client, validation_counter):
client.post("/orders/", json=SAMPLE_ORDER)
assert validation_counter["Order"] == 1
Counting is what the example on this page does, and it generalises: instrument a validator, exercise the endpoint, and assert the count. A regression that introduces a second validation pass fails the test on any machine, which a timing assertion cannot promise.
Two further checks are worth having. Assert that routes you care about declare a response_model, which prevents someone removing it for a speed that is not there. And when using model_construct, add a test that the same input passes model_validate — that is what converts "this data is trusted" from an assumption into something CI verifies.
For endpoint-level tests, dependency_overrides lets you substitute data sources so the measurement reflects model work rather than database latency; see Overriding Dependencies in Tests.
Failure Modes and Diagnosis
Latency scales with payload size faster than expected. Diagnosis: more than one validation pass over the same data. Instrument a validator on the model in question and count invocations per request.
A config flag was enabled and nothing changed. Diagnosis: revalidate_instances on the containing model rather than the contained one, as measured above. Check which class carries the setting.
Mutation-heavy code is unexpectedly slow. Diagnosis: validate_assignment is on, so every attribute write is a validation. Build the object fully, then freeze it, rather than validating each intermediate state.
Startup or cold start regressed. Diagnosis: core schema compilation across a large model graph, or adapters being constructed at import in a loop. Not a request-path issue.
Memory grows steadily under load. Diagnosis: models or adapters created dynamically per request or per tenant. Each carries a compiled schema that is never released. Cache them by type.
A 500 from serialization after a "performance" change. Diagnosis: model_construct on data that was not valid, producing an object that serializes wrongly or fails against the declared response model.
Choosing Where to Spend Effort
| Fix | Work removed | Risk | Do it when |
|---|---|---|---|
| Stop re-validating trusted objects | a whole traversal per occurrence | none | always — it is also simpler code |
| Return fewer nodes | proportional to what you cut | contract change | responses embed collections you did not need |
| Return the model, not a dict | one validation per response | none | any handler calling model_dump before returning |
| Choose a faster serialization call | the intermediate structure | none | serialization dominates a profile |
Reuse a module-level TypeAdapter | schema compilation per call | none | validating non-model data on a hot path |
model_construct | all validation | high — nothing is checked | data your own code produced and CI verifies |
The top of the table is where the durable wins are, and they mostly make the code shorter. The bottom row is the only one that trades safety for speed, which is why it is last and why it is the one with its own warning page.
FAQ
Where does Pydantic time actually go in a FastAPI request? Into four places: validating the incoming body once, any re-validation your own code causes downstream, work created by configuration flags you enabled, and serializing the response. The first is unavoidable and usually small. The other three are where nearly all recoverable time is, and only the fourth is obvious in a profile.
Does passing an already-validated model into another model re-validate it? No, not by default. Pydantic recognises that the value is already an instance of the declared type and accepts it without re-running its validators. That short-circuit is what makes it cheap to compose models, and turning it off is an explicit configuration choice.
Which side does revalidate_instances belong on? On the model that gets revalidated, not on the model that contains it. Setting it on the outer model has no effect at all, which is measured on this page. If you want a nested model re-checked when it is placed in a parent, the setting goes on the nested model's own config.
Is validate_assignment expensive? It costs one full field validation per attribute write, where the default is zero. That is cheap for a settings object written once and expensive for a model mutated in a loop. It is a correctness feature with a running cost, so enable it where invariants matter and not as a global default.
How should I decide what to optimize first? Profile a representative request before changing anything, because the intuition about which model operation dominates is usually wrong. Then work in order of how much work each fix removes: eliminating a duplicated validation beats speeding one up, and returning fewer nodes beats serializing the same nodes faster.
Related Reading
- Up to the section: Advanced Pydantic Validation and Serialization for how model performance relates to the rest of the data layer.
- Choosing how to turn a model into a response body: Pydantic Model Serialization Performance in FastAPI.
- Validating data that is not a model, without inventing one: TypeAdapter for Non-Model Types in Pydantic.
- The escape hatch, and everything it stops checking: model_construct and When to Skip Validation in Pydantic.
- Reducing what you return in the first place: Handling Deeply Nested JSON Models Efficiently.