Where Pydantic runs in a FastAPI request
Pydantic does work at three points in a typical FastAPI request, and each one costs CPU on every call.
Inbound: parsing and validating the body
When an endpoint declares a model parameter, FastAPI reads the request bytes, decodes the JSON, and passes the result to Pydantic for validation against your model. In many FastAPI versions, decoding and validation are separate steps. The body becomes Python dicts and lists first, and only then does Pydantic validate it. That means Pydantic's ability to validate directly from JSON goes unused.
Outbound: validating the return value
If you set response_model or annotate the return type, FastAPI validates your return value against that type. This happens even if you already return an instance of the same model. Depending on the version, FastAPI may also dump the object to plain data before validating it again. For an object your own code just built and checked, that is a full second validation pass.
Outbound: serialization
Next, FastAPI converts the validated result into JSON-compatible data. The response class then encodes that data to bytes. These are two more walks over the whole object graph, separate from validation.
Adding it up
Put together, one endpoint can validate the same data two or three times. Handler code often adds more: calling model_dump() and rebuilding a model, or re-parsing data that was already validated. The core validator in Pydantic v2 is fast, so it is rarely the bottleneck on its own. Most avoidable cost comes from repeated validation, from round-trips between dicts and JSON, and from serialization choices made by habit.
FastAPI and Pydantic internals have changed across releases. Check how your installed versions behave before acting on any of this.
HTTP request bytes
|
v
+------------------------+ +------------------------------+
| 1. JSON decode |--->| 2. Pydantic validate |
| bytes -> dict/list | | dict -> request model [V]|
+------------------------+ +------------------------------+
|
v
+------------------------------+
| Handler code |
| optional: model_dump() and |
| rebuild model [V]|
+------------------------------+
|
v
+------------------------------+
| 3. response_model / return |
| annotation check [V]|
+------------------------------+
|
v
+------------------------+ +------------------------------+
| 5. Response class |<---| 4. Convert to JSON-compatible|
| encode -> bytes | | data (full graph walk) |
+------------------------+ +------------------------------+
|
v
HTTP response bytes
[V] = validation pass; step 3 may also dump first (version-dependent)
One FastAPI request can validate the same data two or three times (marked [V]) and walk the full object graph in two separate passes to produce response bytes; check how your installed FastAPI and Pydantic versions behave.
The double-validation trap and how to detect it
The pattern looks harmless. A handler fetches a row, builds UserOut(**row) so the editor can type-check it, and returns that instance from a route declared with response_model=UserOut. The handler has already validated the data once. Depending on your FastAPI and Pydantic versions, the framework may then dump the instance to a dict and validate it again against the response field, or it may recognize that the object already has the right type and skip most of that work. The cost grows with payload size. On a list endpoint returning a few hundred nested objects, the repeated work can matter.
Check what your setup does instead of guessing. Two methods are cheap:
- Count validator calls. Add a temporary
model_validatororfield_validatorto the response model that increments a counter or logs once per call. Send one request. If the count is two per object, the data is validated twice. - Profile one request. Run the endpoint under
cProfileor a sampling profiler such aspy-spy. Look for repeated calls into Pydantic's core validation from both your handler frame and FastAPI's response-serialization frame.
Once you know the answer, choose one path:
- Build the model in the handler. Return the instance, rely on the return annotation, and confirm that FastAPI serializes it without validating it again.
- Return plain data. Return dicts or ORM objects and let
response_modelvalidate them once at the boundary.
Do not remove response_model just to save the second validation. It drives the OpenAPI schema. It also filters the response, which keeps fields such as password_hash out of the output when the handler returns a broader object.
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, model_validator
CALLS = {"count": 0}
class UserOut(BaseModel):
id: int
email: str
# Temporary instrumentation: increments once per UserOut validation.
@model_validator(mode="after")
def count_calls(self):
CALLS["count"] += 1
return self
app = FastAPI()
# Placeholder row; password_hash must never reach the response.
ROW = {"id": 1, "email": "ada@example.com", "password_hash": "not-a-real-hash"}
@app.get("/built", response_model=UserOut)
def built():
# Pattern under test: validated here, possibly validated again by FastAPI.
return UserOut(**ROW)
@app.get("/plain", response_model=UserOut)
def plain():
# Plain data: response_model validates once and filters out password_hash.
return ROW
client = TestClient(app)
for path in ("/built", "/plain"):
CALLS["count"] = 0
body = client.get(path).json()
# A count of 2 on /built means your versions validate the response twice.
print(path, "validations:", CALLS["count"], "body:", body)
A temporary model_validator counts how many times UserOut is validated per request, so you can see whether returning a pre-built instance triggers a second validation in your FastAPI and Pydantic versions.
How to implement faster validation and serialization paths
When the bytes you need to validate didn't come from a FastAPI request body (a queue message, a cache entry, a response from an upstream service), the usual pattern is json.loads followed by Model.model_validate(data). That builds a full tree of Python dicts, lists and strings just so the Rust core can walk it again and build another set of objects. Model.model_validate_json(raw) hands the bytes straight to the core. The core parses and validates in one pass, and the intermediate dict is never created. For anything that isn't a single model, use TypeAdapter(list[Item]).validate_json(raw) or the same with dict[str, Item].
Build those adapters once, at module level. Creating a TypeAdapter generates a core schema and compiles a validator and a serializer. That costs far more than validating one small payload, and if it sits inside a handler you pay it on every request.
The output side has the same issue. model_dump() followed by a separate encoder builds Python dicts and then walks them a second time. model_dump_json() writes JSON directly from the core.
| Task | Slower path | Faster path |
|---|---|---|
| Validate raw JSON | json.loads + model_validate |
model_validate_json |
| Validate collections | TypeAdapter per call |
Module-level TypeAdapter.validate_json |
| Serialize | model_dump + external encoder |
model_dump_json |
These gains come with costs:
- Startup time and memory. Module-level adapters and cached schemas slow startup and take up memory.
- Runtime-generated models. Creating many generic parametrizations or dynamic models with
create_modelat runtime moves schema building onto the hot path. When those models are never reused, it can also grow memory without bound.
import json
from pydantic import BaseModel, TypeAdapter
class Item(BaseModel):
id: int
name: str
price: float
# Built once at import time: schema generation and validator/serializer
# compilation happen here, not on every message or request.
ITEMS_ADAPTER = TypeAdapter(list[Item])
ITEMS_BY_SKU_ADAPTER = TypeAdapter(dict[str, Item])
def parse_item_slow(raw: bytes) -> Item:
# Builds an intermediate Python dict, then the core walks it again.
return Item.model_validate(json.loads(raw))
def parse_item_fast(raw: bytes) -> Item:
# Bytes go straight to the Rust core: parse and validate in one pass.
return Item.model_validate_json(raw)
def parse_queue_batch(raw: bytes) -> list[Item]:
# Reuses the module-level adapter instead of TypeAdapter(...) per call.
return ITEMS_ADAPTER.validate_json(raw)
def parse_cache_entry(raw: bytes) -> dict[str, Item]:
return ITEMS_BY_SKU_ADAPTER.validate_json(raw)
def encode_item(item: Item) -> str:
# Slower alternative: json.dumps(item.model_dump()) builds dicts first.
return item.model_dump_json()
def encode_batch(items: list[Item]) -> bytes:
# Adapters serialize collections in the core too; dump_json returns bytes.
return ITEMS_ADAPTER.dump_json(items)
Validating raw JSON bytes with model_validate_json and module-level TypeAdapters for list[Item] and dict[str, Item], and serializing with model_dump_json and dump_json instead of round-tripping through Python dicts and json.
Model design choices that change validation cost
Each schema decision below either keeps work inside pydantic-core, the compiled validation engine, or forces extra passes and calls back into Python. The cost scales with request volume and payload size, so the same choice can be negligible on an admin endpoint and significant on a high-throughput ingest route.
| Choice | Why it costs (or saves) time | Trade-off |
|---|---|---|
| Strict vs. lax mode | Lax mode tries coercions such as "42" → 42. Strict mode accepts only exact types and skips those attempts. |
Strict mode rejects inputs clients send today, such as numeric strings. Turning it on can be a breaking API change. |
| Deeply nested models | Every level is a separate validator with its own type checks and error-location tracking. Lists of nested models multiply that work per item. | Flattening hurts readability and reuse, so only flatten the hot paths. |
| Plain vs. discriminated unions | A plain union may try members in turn until one fits. A discriminated union reads a tag field and validates only the matching member. | It needs a tag field, which may change the schema clients depend on. |
field_validator vs. built-in constraints |
A custom validator calls Python for every value. Constraints like ge, max_length or pattern run inside the core. |
Constraints cover common checks only. Cross-field logic still needs Python. |
computed_field |
It runs on every serialization, not during validation, so the cost moves to model_dump and response encoding. |
It appears in output and in the JSON schema, so removing it later breaks clients. |
The code example that follows shows two of these changes. It uses a kind literal as the union discriminator, and it replaces a hand-written "must be positive" validator with Field(gt=0).
from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field, field_validator
# Before: a custom validator calls back into Python for every value
class LineItemSlow(BaseModel):
quantity: int
@field_validator("quantity")
@classmethod
def must_be_positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("quantity must be positive")
return v
# After: the same rule as a built-in constraint, checked inside pydantic-core
class LineItem(BaseModel):
quantity: int = Field(gt=0)
class CardPayment(BaseModel):
kind: Literal["card"] # tag value clients must now send
last4: str = Field(pattern=r"^\d{4}$")
class BankPayment(BaseModel):
kind: Literal["bank"]
iban: str = Field(max_length=34)
class Order(BaseModel):
items: list[LineItem]
# The "kind" tag picks one member directly instead of trying each in turn
payment: Annotated[Union[CardPayment, BankPayment], Field(discriminator="kind")]
order = Order.model_validate(
{"items": [{"quantity": 2}], "payment": {"kind": "bank", "iban": "GB00EXAMPLE0000000000"}}
)
A discriminated union keyed on a kind literal, and a Python field_validator replaced by the equivalent built-in Field(gt=0) constraint that runs in pydantic-core.
Alternatives: orjson responses, raw bytes and model_construct
The previous section's changes lower validation cost. The options below remove work entirely, and each one also removes something you may be relying on.
orjson responses
ORJSONResponse, or a custom response class built on orjson, replaces only the final step of turning Python objects into bytes. If the handler still sends its data through jsonable_encoder or model_dump first, the expensive part is unchanged and the gain may be small. orjson helps most with large payloads of plain dicts and lists that never pass through Pydantic, such as rows from a query driver or a cached aggregate.
The trade-offs:
- It adds a native-code dependency, which affects build images and platform support.
- Its output is not identical to the standard library's. Float formatting, datetime and timezone rendering, non-string dict keys and NaN are all handled differently. Clients that compare strings or parse strictly can break.
Pre-serialized bytes and model_construct
Returning a Response with bytes you serialized yourself skips FastAPI's response handling completely. model_construct builds a model instance without running validation. Both are trusted-data escape hatches.
| Approach | What it skips | What you lose |
|---|---|---|
| orjson response class | Standard-library encoding | Byte-identical output |
Raw Response bytes |
response_model processing |
Field filtering, defaults, type checks, the contract |
model_construct |
Validation | Type checks, coercion, validators |
When these checks are gone, an internal field can leak to clients and a string can arrive where an integer was promised. Nothing raises an error, so clients receive the bad data silently. Use these escape hatches only on trusted, well-tested paths, and add contract tests that compare the output against the schema.
How to measure before you optimize
Measure at three levels, because each one answers a different question.
Isolated calls. Use pytest-benchmark or timeit to time model_validate and model_dump_json on a realistic payload taken from production traffic, not a toy object. This shows the cost per call and lets you compare model variants against each other. It cannot tell you whether that cost matters.
Running server. Attach py-spy to a worker under realistic traffic and record a flame graph. Sampling shows where CPU time actually goes, including time spent in middleware, dependency injection, and your own code that you would not think to benchmark. If validation and serialization frames are thin, stop here.
End to end. Run a load test before and after each change and compare p50 and p99 latency at a fixed request rate. This is the only number users experience. Micro-benchmark wins often disappear next to database and network time.
Illustrative estimate
Assumed inputs: a response of 500 items at about 200 bytes each (around 100 KB), 4 µs to serialize each item, 25 ms of database time, 3 ms of other overhead, and 200 requests per second.
- Serialization: 500 × 4 µs = 2 ms per request
- Share of latency: 2 / (2 + 25 + 3) ≈ 7%
- CPU budget: 200 × 2 ms = 400 ms of CPU per second, about 0.4 of a core
If you halve serialization time, latency improves by about 1 ms, roughly 3%. That is barely visible to users. The same change frees about 0.2 of a core, which may matter for fleet cost. Decide which of these two goals you are optimizing for before you start.
Who should tune this, when to skip it, and the cost in engineering time
If the goal is latency, the service has to be CPU-bound for any of this to matter. That usually means high-throughput endpoints, large response payloads, or gateways that relay upstream JSON they never inspect. If the goal is fleet cost, the bar is lower, but the saving still has to beat the engineering time spent getting it.
Skip tuning when the database or downstream calls dominate the request. Shaving validation from a request that waits on a slow query changes nothing users can see. The extra code paths stay behind to be maintained.
The real cost is review burden. When you bypass a response model, nothing filters fields any more. An internal column or secret can then reach clients unnoticed. model_construct skips validation entirely, so malformed data passes silently. Every escape hatch needs a test and a reviewer who understands why it exists.
| Change | Risk | Review burden |
|---|---|---|
model_validate_json instead of json.loads + model_validate |
Low | Minimal |
Module-level TypeAdapter |
Low | Minimal |
| Removing redundant validation | Low–medium | Confirm one remaining check |
| orjson response class | Medium | New dependency, encoding differences |
Raw bytes / bypassed response_model |
High | Field leakage, schema drift |
model_construct on untrusted input |
High | Correctness and security |
Do the low-risk rows first. They often recover most of the time with almost no new surface area.
Decision checklist
- Does a profiler show Pydantic in the hot path?
- Are you targeting latency or CPU cost?
- Have the low-risk changes already been applied?
- Does each bypass have a test proving output fields are unchanged?
- Have you verified behaviour against your installed Pydantic and FastAPI versions, rather than relying on older posts?
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to pydantic v2 performance, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.