How Pydantic v2 validation works under the hood
Pydantic v2 splits into two layers. The Python package exposes the API you write against: BaseModel, Field, TypeAdapter, validators. It does not do the validation itself. When a model class is defined, Pydantic inspects its annotations, field settings and validators and produces a core schema, a nested description of every type, constraint and coercion rule. pydantic-core, written in Rust, compiles that schema into two objects: a validator and a serializer. The model keeps them for every later call. TypeAdapter goes through the same process for types that are not models, such as list[int] or a TypedDict.
This design determines where the costs fall.
- Schema building is expensive but happens once. It runs when the class is created or when a
TypeAdapteris constructed. If that happens inside a request handler, you pay the full cost on every request. - Validation calls are cheap.
model_validateandmodel_validate_jsonhand the input to the compiled validator. Type checks, coercion and constraint checks run mostly in Rust. - JSON input is cheapest when Rust parses it directly.
model_validate_jsonparses and validates in one pass. Callingjson.loadsfirst builds an intermediate Python dict for no benefit. - Python callbacks bring back interpreter overhead. Custom validators and serializers written in Python run inside the interpreter. The output objects are Python objects too.
API boundaries see the same shapes repeatedly at high volume, which is the workload this design suits. Backend and platform engineers running high-throughput services benefit most: the one-time build cost is spread across millions of calls, and the work per request stays small.
ONE-TIME COST: at class creation or TypeAdapter construction
(if this runs inside a request handler, it is paid on every request)
+--------------------------+ +--------------------+ +---------------+
| Python API | | core schema | | pydantic-core |
| BaseModel, Field, |--->| types, constraints |--->| (Rust) |
| TypeAdapter, validators | | coercion rules | | compiles |
+--------------------------+ +--------------------+ +-------+-------+
|
v
PER CALL (hot path) +------------------------------------------------+
| compiled validator + serializer (Rust) |
JSON str/bytes ---------->| model_validate_json: parse+validate, one pass |
(no json.loads needed) | |
Python dict ------------->| model_validate: type checks, coercion, |
| constraints; mostly outside the interpreter |
+--+-----------------------------+---------------+
| |
v v
+-------------------------------+ +--------------------------+
| result: model instance | | PYTHON CALLBACKS |
| built from Python objects | | custom validators and |
| | | serializers run in the |
| | | interpreter: slower |
+-------------------------------+ +--------------------------+
Pydantic v2 builds a core schema from the Python-facing API once, pydantic-core compiles it into a reusable validator and serializer in Rust, and each later call runs mostly outside the interpreter. Python callbacks and the Python objects in the result are where interpreter overhead returns.
Is validation actually your bottleneck? Measuring honestly
Before tuning models, find out what fraction of request time validation actually takes. In many services it is a small slice next to database round trips, network I/O and JSON encoding, and making it faster changes little.
Benchmark with realistic payloads
Use timeit or pytest-benchmark with payloads captured from production, or generated to match their size, nesting depth and union variety. A three-field toy model shows only the per-call overhead. It does not show what your 40-field order document with nested line items costs. Benchmark the method your handler actually calls: validating a JSON string, validating an already-decoded dict, or going through a TypeAdapter. Each takes a different path.
Profile the running service
Microbenchmarks mislead because they run with warm caches and isolated code, and they leave out the I/O your service waits on. Attach a sampling profiler such as py-spy to a process under realistic load and look at the flame graph. Compare the time spent in pydantic-core frames with the time spent in database drivers, HTTP clients and serialization.
Illustrative estimate
These inputs are assumed, not measured: 2,000 requests per second, 4 KB payloads, and 50 µs of validation per request. That is 0.1 CPU-seconds per second, or about 10% of one core. If each request also waits 20 ms on the database, validation is 0.25% of latency. Optimizing it saves CPU but does not make responses noticeably faster.
Re-measure after every pydantic or pydantic-core upgrade. Internal changes can shift these numbers in either direction.
import json
import timeit
from pydantic import BaseModel, TypeAdapter
class LineItem(BaseModel):
sku: str
quantity: int
unit_price: float
class Order(BaseModel):
order_id: str
customer_id: str
currency: str
items: list[LineItem]
# Match production size and nesting. Swap in a captured, anonymized payload if you have one.
payload = {
"order_id": "ord_000123",
"customer_id": "cus_000456",
"currency": "EUR",
"items": [{"sku": f"SKU-{i:04d}", "quantity": i % 5 + 1, "unit_price": 19.99} for i in range(40)],
}
raw = json.dumps(payload)
adapter = TypeAdapter(Order)
# Each entry is a different code path. Benchmark the one your handler actually uses.
cases = {
"model_validate_json(str)": lambda: Order.model_validate_json(raw),
"json.loads + model_validate": lambda: Order.model_validate(json.loads(raw)),
"model_validate(dict)": lambda: Order.model_validate(payload),
"TypeAdapter.validate_json": lambda: adapter.validate_json(raw),
}
N = 10_000
for name, fn in cases.items():
# Take the best of several repeats to reduce noise. This is still a warm-cache microbenchmark.
best = min(timeit.repeat(fn, number=N, repeat=5)) / N
print(f"{name:30s} {best * 1e6:8.1f} us/call")
# Then check the share of real request time under load: py-spy record -o profile.svg --pid <PID>
A timeit microbenchmark that compares Pydantic v2 validation paths on a realistically sized nested payload, followed by a reminder to confirm the result with a sampling profiler on the live service.
Avoidable costs: rebuilt validators and double parsing
Build validators once
Constructing a TypeAdapter is not free. Pydantic generates a core schema for the type and compiles it into a validator in pydantic-core, and that work happens again each time you write TypeAdapter(list[Item]) inside a handler. Models avoid this because their validator is built once when the class is defined. Adapters need the same treatment. Create them at module level, or behind a cache for types assembled at runtime, and reuse them.
Parse and validate in one pass
json.loads followed by model_validate builds a full tree of Python dicts and lists, then walks it again to validate. model_validate_json and TypeAdapter.validate_json take bytes or a string, and pydantic-core parses and validates together without creating that intermediate tree.
Frameworks change the picture. Depending on the version, FastAPI may decode the body before Pydantic sees it. To get raw bytes, you can accept the Request and read the body yourself. The cost is that the endpoint loses the automatic request schema in OpenAPI and FastAPI's standard 422 error formatting, which you then have to rebuild and maintain. Do this only on endpoints that profiling has flagged.
| Situation | Fix | Trade-off |
|---|---|---|
| Adapter created per request | Module-level or cached adapter | None |
| You hold raw bytes | validate_json / model_validate_json |
None |
| Framework pre-parses body | Leave as is, or read raw body | Custom docs and error handling |
Illustrative estimate (assumed numbers, not a benchmark): CPU saved per second = requests/s × per-call construction cost. With 2,000 req/s and an assumed 0.5 ms construction cost, that is 2,000 × 0.0005 = 1 core-second per second, or roughly one full core.
from functools import lru_cache
from typing import Any
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, TypeAdapter, ValidationError
class Item(BaseModel):
sku: str
quantity: int
# Built once at import. Core schema generation and validator compilation
# happen here, not on every request.
ITEMS_ADAPTER = TypeAdapter(list[Item])
@lru_cache(maxsize=128)
def adapter_for(tp: Any) -> TypeAdapter:
# For types assembled at runtime. tp must be hashable, e.g. list[Item].
return TypeAdapter(tp)
# Anti-pattern this replaces (rebuilds the validator and builds a dict tree):
# items = TypeAdapter(list[Item]).validate_python(json.loads(raw))
app = FastAPI()
@app.post("/items/bulk")
async def bulk_items(request: Request) -> dict:
# Raw bytes let pydantic-core parse and validate in one pass.
# Cost: no request schema in OpenAPI and no automatic 422 for this route.
raw = await request.body()
try:
items = ITEMS_ADAPTER.validate_json(raw)
except ValidationError as exc:
# Hand-rolled 422 you now own and must keep consistent with other routes.
raise HTTPException(status_code=422, detail=exc.errors(include_url=False, include_context=False))
return {"accepted": len(items)}
A module-level TypeAdapter, an lru_cache for adapters built from runtime types, and a FastAPI route that reads the raw body and calls validate_json, which means the route needs its own 422 handling.
Choosing types, unions and constraints for fast validation
Every field type maps to a validator in pydantic-core. That makes the choice of type annotation a performance decision as well as a correctness one.
Concrete beats abstract. list[int] and dict[str, int] give the core a narrow, predictable input shape. Abstract types such as Sequence, Mapping, or Any force broader acceptance checks, and some of them do extra work to preserve the input's type. Use abstract types only when callers actually send different container types.
Wide unions are a search, discriminated unions are a lookup. For a plain A | B | C | D union, Pydantic may try members in turn and score partial matches, so the cost grows with the number of variants. If each variant carries a Literal tag and the field declares Field(discriminator="kind"), the core reads the tag and dispatches straight to one validator. Error messages also get shorter. The trade-off is that the tag must be present on the wire, which can be a breaking change for existing clients.
Nesting multiplies. Per-field cost is paid once for every item at every level. A list of 500 orders with 20 line items each runs the line-item validators 10,000 times. A small extra cost on an inner model shows up across the whole payload.
Prefer declared constraints over Python hooks. Field(gt=0, max_length=64, pattern=...), written directly or through Annotated, runs inside the compiled core. A field_validator or model_validator crosses into Python for every value. before and wrap modes are the costliest, because they handle raw input or wrap the core call itself.
Moving rules into types has costs too. Business logic can end up scattered across type aliases. Cross-field checks, database lookups, and rules that change at runtime still need Python. Keep those as validators, and keep them few.
from typing import Annotated, Literal
from pydantic import BaseModel, Field, model_validator
# Reusable constrained types: checks run inside pydantic-core, no Python hook per value.
Sku = Annotated[str, Field(pattern=r"^[A-Z0-9-]{4,32}$")]
Qty = Annotated[int, Field(gt=0, le=1000)]
Cents = Annotated[int, Field(ge=0)]
# Each variant carries a Literal tag so the core can dispatch directly.
class CardPayment(BaseModel):
kind: Literal["card"]
last4: Annotated[str, Field(min_length=4, max_length=4)]
class BankPayment(BaseModel):
kind: Literal["bank"]
iban: Annotated[str, Field(max_length=34)]
# Discriminated union: one tag lookup instead of trying every member.
Payment = Annotated[CardPayment | BankPayment, Field(discriminator="kind")]
class LineItem(BaseModel): # inner model: validated once per item, keep it lean
sku: Sku
qty: Qty
unit_price_cents: Cents
class Order(BaseModel):
order_id: Annotated[str, Field(max_length=64)]
items: list[LineItem] # concrete list, not Sequence
payment: Payment
discount_cents: Cents = 0
# Cross-field rule genuinely needs Python; 'after' runs once on validated data.
@model_validator(mode="after")
def discount_within_total(self) -> "Order":
total = sum(i.qty * i.unit_price_cents for i in self.items)
if self.discount_cents > total:
raise ValueError("discount_cents exceeds order total")
return self
An order model that uses concrete containers, Annotated constraints and a Literal-tagged discriminated union for core-side validation, reserving a single 'after' model_validator for a cross-field rule that needs Python.
Strict mode and skipping validation: when to use them and when to avoid them
By default, Pydantic v2 runs in lax mode. A field typed int accepts "42" and a bool accepts "true", because the core validator tries a set of coercions before it fails. This is convenient for query strings and form data. On JSON bodies it can hide client bugs, and every coercion attempt adds a small amount of work.
Strict mode turns those coercions off. You can enable it on one field with Field(strict=True), on a whole model with ConfigDict(strict=True), or for a single call with model_validate(..., strict=True). Most of the gain is in correctness, because type mismatches get rejected instead of silently converted. Any speed improvement is usually small. Strict JSON validation still parses ISO strings into datetimes, since JSON has no native datetime type.
model_construct skips validation completely. It assigns the values you pass in and fills in defaults, so it is the fastest option. It also checks nothing.
| Approach | Correctness effect | Speed effect | Main risk |
|---|---|---|---|
| Lax (default) | Coerces compatible inputs | Baseline | Hides malformed client data |
Strict (field or ConfigDict) |
Rejects type mismatches | Usually modest gain | Breaks clients that relied on coercion |
model_construct |
No checks at all | Fastest | Invalid objects spread silently |
Switching a public endpoint to strict mode is a breaking API change. A client that sent "1" for years will start receiving 422 responses. Ship it behind a new API version, or log what would have been rejected before you enforce it.
Only use model_construct for data that your own process just validated. "Trusted" data from a cache or another service is not safe. A poisoned cache entry, or an upstream service whose schema has drifted, will produce model instances that break invariants, and the failure shows up far from where the bad data came in.
Serialization and FastAPI response handling
Both model_dump and model_dump_json hand the work to pydantic-core's Rust serializer, so neither walks your model in Python. The difference is the output. model_dump builds a full tree of Python dicts, lists and strings. If the next step is json.dumps or a framework encoder, that tree is allocated, walked a second time in Python, and then discarded. model_dump_json writes bytes directly from the Rust side and skips the intermediate objects. For large or deeply nested payloads, that intermediate tree accounts for much of the allocation and garbage-collection pressure on the response path.
Where FastAPI can add a pass
When a route declares response_model or a return type annotation, FastAPI treats the declared type as a contract for the response. Depending on the version, enforcing that contract can mean validating your returned object against the response type again, even if it is already an instance of that model. It can also mean converting the object to plain Python data before it is encoded to JSON. Either step repeats work you have already paid for. The exact sequence has changed between FastAPI releases, so documentation and blog posts written against a different release are not reliable evidence for yours.
Check your installed stack directly instead of assuming:
- Profile a representative endpoint with a sampling profiler such as py-spy, or with
cProfile. Look for validator calls or encoder functions that appear after your handler returns. - Read the response-serialization code in FastAPI's routing module in your environment's
site-packages. That file shows what your installed version actually does with the return value.
If you confirm a redundant pass, you can return a Response that wraps model_dump_json() output. FastAPI sends a returned Response as-is, which bypasses its response-model processing. The cost is that the framework no longer filters or validates that output for you, so the route's correctness depends on your code alone.
Migration, alternatives and impact on engineering cost
That trade-off generalizes beyond responses. Most v2 performance work is deciding where validation belongs and then removing work you did not intend to do.
Migration pitfalls that change behavior
Renames are the easy part: parse_obj becomes model_validate, .dict() becomes model_dump, and @validator/@root_validator become @field_validator/@model_validator. The old methods still work but are deprecated, and the new decorators take different arguments, so shims can hide subtle bugs. The riskier change is coercion. v2's lax mode accepts a different set of inputs than v1. For example, it no longer turns numbers into strings. A payload that passed for years can start returning 422s, or a value v1 rejected can now pass. Diff behavior with real captured payloads, and use the official migration guide as the checklist. Do not rely on a list remembered from a blog post.
Alternatives
| Option | Validates input | Good fit |
|---|---|---|
BaseModel |
Yes | Trust boundaries: requests, queues, config |
TypeAdapter over TypedDict |
Yes | Validated dicts without model instances |
stdlib dataclasses |
No | Internal data passed between your own functions |
model_construct |
No | Data already validated upstream |
Startup and engineering cost
v2 builds each model's core schema when the class is defined, so hundreds of models add import time. A long-running server pays this once. Serverless cold starts pay it on every new instance. Measure import time before tuning per-request paths. Then put your effort into structural fixes: validate each payload once, reuse adapters, and keep validation off internal hops. Those changes are cheap to review and keep paying off. Micro-tuning types rarely gives the same return on the hours spent.
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.