At high request rates, validation and JSON serialization are often the largest CPU cost in a FastAPI service.
Pydantic v2 splits into Python and pydantic-core, written in Rust. When a model class is created, Pydantic builds a core schema and compiles it into a SchemaValidator and a SchemaSerializer. Validation and serialization then run mostly in Rust, calling back into Python only for custom validators and serializers. Building that schema is the expensive part, which is why you create TypeAdapters once at module level, never inside a request.
FastAPI builds the validators and serializers for every parameter, body and response model when routes are registered. Per request, the JSON body is parsed with json and validated with validate_python. On the way out, since FastAPI 0.130, a route with a declared return type or response_model (and the default response class) is serialized straight to JSON bytes by pydantic-core, skipping the older jsonable_encoder plus json.dumps path. Routes without a declared type still take the slow path, so declaring response types is also a performance feature. That's also why ORJSONResponse was deprecated in 0.131.
Practical rules for hot paths:
- Keep logic in constraints (
Field(gt=0),pattern) rather than Python@field_validators, which cross back into Python per value. - Use discriminated unions,
Annotated[Cat | Dog, Field(discriminator="kind")], instead of plain unions: one dictionary lookup instead of trying each member, plus clearer errors. strict=Trueturns off coercion when inputs are already typed.- Don't reach for
model_construct()for speed: it skips validation but runs in Python, and is often slower than validating in Rust. - For huge, already-serialized payloads, return a
Responsewith bytes directly, accepting that you lose validation and docs.
Measure with a profiler before optimizing; the database is usually slower still.
Example
import json, timeit
from typing import Annotated, Literal
from pydantic import BaseModel, Field, TypeAdapter
class Cat(BaseModel):
kind: Literal["cat"]
lives: int = 9
class Dog(BaseModel):
kind: Literal["dog"]
good: bool = True
Pet = Annotated[Cat | Dog, Field(discriminator="kind")]
pets = TypeAdapter(list[Pet]) # build once: schema compilation is costly
raw = json.dumps([{"kind": "dog"} if i % 2 else {"kind": "cat"} for i in range(10_000)])
objs = pets.validate_json(raw)
print(type(objs[1]).__name__) # Dog
slow = timeit.timeit(lambda: json.dumps(pets.dump_python(objs, mode="json")), number=20)
fast = timeit.timeit(lambda: pets.dump_json(objs), number=20)
print(f"dump_json is {slow / fast:.1f}x faster") # typically 1.5-3x (varies)Remember
- Pydantic v2 compiles models into Rust validators and serializers; building them is the costly step
- Since 0.130, declared response types serialize directly to JSON bytes in Rust
- Prefer constraints and discriminated unions over Python validators and plain unions
- Create TypeAdapters once; don't assume model_construct is faster
Watch out for
- Creating a TypeAdapter inside the request handler, recompiling the schema on every call.