At scale, validation can dominate request time, and small modeling choices change its cost severalfold.
Pydantic v2 is fast by default, but the way you use it still matters:
- Validate JSON directly.
Model.model_validate_json(raw)parses and validates in one Rust pass.model_validate(json.loads(raw))builds Python objects twice. - Build once. Models,
TypeAdapters and parametrized generics compile a validator when created. Define them at import time, never per request. - Be specific.
list[int]is cheaper thanSequence[int], anddictcheaper thanMapping, because abstract types need extra checks and conversions. AvoidAny-heavy models when you need guarantees. - Use discriminated unions. A tagged union jumps to one member; a plain union of many models may try them all.
- Prefer TypedDict for large, internal nested data when you don't need model methods; it validates faster than nested models.
- Skip work you don't need.
model_construct(**data)creates an instance without validation for data you already trust, such as rows you just wrote; it does not apply validators or conversions. - Wrap validators cost Python round-trips. Every Python-level validator crosses the Rust/Python boundary; built-in constraints like
Field(gt=0)stay in Rust.
Startup time is the other axis: thousands of models can slow imports. ConfigDict(defer_build=True) postpones schema building until first use, which helps CLIs and serverless cold starts.
Measure before optimizing: timeit on a realistic payload usually shows whether JSON parsing, a slow custom validator or model count is the real cost.
Example
import json, timeit
from pydantic import BaseModel, ConfigDict
class Item(BaseModel):
id: int
name: str
price: float
class Batch(BaseModel):
model_config = ConfigDict(defer_build=True) # compile on first use
items: list[Item]
raw = json.dumps({"items": [{"id": i, "name": f"n{i}", "price": i / 3} for i in range(2000)]})
one_pass = timeit.timeit(lambda: Batch.model_validate_json(raw), number=50)
two_pass = timeit.timeit(lambda: Batch.model_validate(json.loads(raw)), number=50)
print(one_pass < two_pass) # True on typical machines
trusted = Item.model_construct(id=1, name="x", price=2.0) # no validation at all
print(trusted) # id=1 name='x' price=2.0Remember
- model_validate_json beats json.loads + model_validate
- Create models and adapters once; defer_build=True helps startup
- Specific types, discriminators and Rust-side constraints are cheaper than Python validators
- model_construct skips validation for already-trusted data
Watch out for
- Using model_construct on untrusted input, which bypasses every check.