Performance tuning

Pydantic · Expert · 12 min

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 than Sequence[int], and dict cheaper than Mapping, because abstract types need extra checks and conversions. Avoid Any-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.0

Remember

  • 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.

Check yourself

  1. Which change usually speeds up validating a large JSON API response the most?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.pydantic.dev/latest/concepts/performance/