How does Pydantic v2 validation work under the hood?

Pydantic v2 splits work between two layers. The Python package inspects your type hints, Field constraints and config. It then compiles them into a core schema, a nested description of every field, type, constraint and validator. That schema is handed to pydantic-core, a library written in Rust. pydantic-core builds a validator and a serializer from it. For a BaseModel, this happens once when the class is defined. For a TypeAdapter, it happens when the adapter is constructed. After that, each call to model_validate runs the precompiled Rust validator rather than re-reading annotations.

That design puts cost in three places:

  • Schema construction. Building the core schema is comparatively expensive. It is paid at import time or adapter creation. If you create models or adapters dynamically inside a request handler, you pay it repeatedly.
  • The Python/Rust boundary. Rust can only work on Python objects by reading them through the interpreter's API. Each dict, list and string it traverses has a cost. Input that arrives as Python objects is handled differently from input that arrives as raw JSON bytes, which Rust can parse directly.
  • Python-level hooks. field_validator, model_validator and custom serializers are Python functions. Rust must call back into the interpreter for each one, per value. Those callbacks run at Python speed and can dominate the total cost.

This matters most to backend and platform engineers running high-throughput services, where per-request overhead multiplies across many requests. Even there, validation is often not the bottleneck. Database round trips, network I/O and large JSON payloads usually cost far more. Profile before tuning.

 ONCE PER MODEL OR TYPE (class definition / TypeAdapter creation)
+---------------------------+      +-----------------------------------+
| Python: pydantic          |      | Rust: pydantic-core               |
| hints, Field, config      |----->| builds validator + serializer     |
| -> core schema            |      | from the core schema              |
+---------------------------+      +-----------------------------------+
 COST 1: schema construction                         |
                                                     v
 PER CALL (model_validate / model_validate_json)
+--------------------+             +-----------------------------------+
| Python objects     |--COST 2---->| precompiled Rust validator        |
| dict / list / str  |  boundary   | reads Python objects via the      |
+--------------------+  crossing   | interpreter API, item by item     |
+--------------------+             |                                   |
| raw JSON bytes     |------------>| parses JSON bytes directly        |
+--------------------+             +-----------------------------------+
                                             |                 ^
                                             | call per value  | result
                                             v                 |
                                   +-----------------------------------+
                                   | COST 3: Python-level hooks        |
                                   | field_validator, model_validator, |
                                   | custom serializers: Python speed  |
                                   +-----------------------------------+

+-----------------------------------------------------------------------+
| Often costs more per request: DB round trips, network I/O, JSON size  |
| Profile before tuning validation.                                     |
+-----------------------------------------------------------------------+

Pydantic v2 compiles a core schema once per model or TypeAdapter into a Rust validator. Per-call cost then lands at the Python/Rust boundary and in Python-level hooks, and is often smaller than database, network and payload costs.

Why use model_validate_json and TypeAdapter instead of json.loads and per-request schemas?

When the payload arrives as JSON, the usual pattern is Model.model_validate(json.loads(body)). That parses the bytes into a full tree of Python dicts, lists and strings, then walks that tree again to validate it. model_validate_json passes the raw str or bytes to pydantic-core, which parses and validates in a single pass and never builds the intermediate dict. How much this saves depends on the payload. Deeply nested objects and large arrays of small records gain the most. A flat payload with a few fields may show no measurable difference, so benchmark with real request bodies.

For types that are not models, such as list[Item], dict[str, Price] or Item | Error, use TypeAdapter. It provides the same validate_json, validate_python and dump_json methods. Constructing an adapter compiles a core schema, and that step is expensive. A common anti-pattern is calling TypeAdapter(list[Item]) inside a request handler or a loop, which recompiles the schema on every call. Build the adapter once at module level, or lazily behind a cached factory such as functools.cache, and reuse it.

Each option has a cost. Module-level adapters move schema compilation to import time. That slows serverless cold starts and short-lived CLI tools that may never use the adapter. Deferring construction avoids the import cost but adds it to the first request instead. model_validate_json also needs the whole payload in memory. It is slower when your data is already Python objects, because you would have to encode them to JSON before validating. In that case, use validate_python directly.

import functools
import json

from pydantic import BaseModel, TypeAdapter


class Item(BaseModel):
    sku: str
    qty: int


body = b'[{"sku": "A-100", "qty": 2}, {"sku": "B-200", "qty": 5}]'

# Two passes: json.loads builds a full list of dicts, then pydantic walks it again.
items_slow = TypeAdapter(list[Item]).validate_python(json.loads(body))


# Anti-pattern: the core schema is recompiled on every call.
def handle_request_bad(raw: bytes) -> list[Item]:
    return TypeAdapter(list[Item]).validate_json(raw)


# Option 1: build once at import time. Fast per request, slower cold start.
ITEMS_ADAPTER = TypeAdapter(list[Item])


def handle_request(raw: bytes) -> list[Item]:
    # One pass: pydantic-core parses and validates the bytes directly.
    return ITEMS_ADAPTER.validate_json(raw)


# Option 2: build lazily on first use. No import cost; the first call pays it.
@functools.cache
def items_adapter() -> TypeAdapter[list[Item]]:
    return TypeAdapter(list[Item])


# Data already in Python objects: validate directly, don't re-encode to JSON.
rows = [{"sku": "C-300", "qty": 1}]
items_from_rows = items_adapter().validate_python(rows)

# Single models get the same one-pass path via model_validate_json.
one = Item.model_validate_json(b'{"sku": "D-400", "qty": 3}')

Compares json.loads plus validation against one-pass validate_json, shows a per-request TypeAdapter beside module-level and functools.cache versions, and uses validate_python when the data is already Python objects.

What does Pydantic strict mode change, and when should you use it?

TypeAdapter.validate_python or model_validate on the objects you already have.

Strict mode is a contract tool, not a speed setting. By default Pydantic runs in lax mode and coerces compatible input: the string "123" becomes 123 for an int field, and "true" becomes True for a bool. Strict mode turns that coercion off, so a client that sends the wrong type gets a validation error instead of having its data silently reinterpreted.

You can enable it at three levels:

  • Per field: Field(strict=True), or strict annotations such as StrictInt or Annotated[int, Strict()].
  • Per model: strict=True in model_config.
  • Per call: model_validate(data, strict=True) and the equivalent argument on the JSON and TypeAdapter methods.

Strict does not mean the same thing for every input mode. JSON has no native date, UUID or tuple types, so strict JSON validation still accepts an ISO string for a datetime field. Strict Python validation requires an actual datetime object. A single model can therefore accept a value through a JSON request body and reject the same value when it arrives as a Python dict built from form or query data.

Input to an int / datetime field Lax Strict, JSON Strict, Python
"123" accepted rejected rejected
"true" for bool accepted rejected rejected
ISO date string accepted accepted rejected

Any speed difference depends on the workload, so measure it rather than assume it. The main risk is breaking existing clients that send numeric strings, string booleans or dates as strings. Before you switch a public endpoint, add contract tests that replay real client payloads. Then run a lax period in which you log any input that would fail strict validation, and enforce strict mode only after those logs are empty.

from datetime import datetime
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, Strict, TypeAdapter, ValidationError

# Level 1: per field. Only these fields refuse coercion.
class Order(BaseModel):
    order_id: int = Field(strict=True)
    quantity: Annotated[int, Strict()]
    created_at: datetime  # still lax

# Level 2: per model. Every field is strict.
class StrictOrder(BaseModel):
    model_config = ConfigDict(strict=True)
    quantity: int

# Default (lax) model. Level 3: strictness is chosen per call.
class Event(BaseModel):
    count: int
    created_at: datetime

lax_dict = {"count": "123", "created_at": "2026-11-04T10:00:00"}
print(Event.model_validate(lax_dict))  # lax: '123' -> 123, ISO string -> datetime

# Strict JSON still parses ISO strings, because JSON has no datetime type.
Event.model_validate_json('{"count": 123, "created_at": "2026-11-04T10:00:00"}', strict=True)

# Strict Python input rejects the same ISO string: it requires a datetime object.
try:
    Event.model_validate({"count": 123, "created_at": "2026-11-04T10:00:00"}, strict=True)
except ValidationError as exc:
    print("python strict:", [e["loc"] for e in exc.errors()])  # [('created_at',)]

# Lax rollout period: log what strict mode would reject, but keep accepting it.
def validate_with_shadow_strict(data: dict) -> Event:
    try:
        return Event.model_validate(data, strict=True)
    except ValidationError as exc:
        print("would fail strict:", exc.errors(include_url=False))  # send to your logger
        return Event.model_validate(data)  # lax fallback keeps old clients working

TypeAdapter(int).validate_python("123")  # 123 in lax; add strict=True to reject

Shows strict mode at field, model and per-call level, demonstrates that strict JSON accepts an ISO datetime string while strict Python input rejects it, and sketches a lax-period wrapper that logs payloads strict validation would reject.

Which serialization path is fastest: model_dump, model_dump_json or TypeAdapter.dump_json?

Pydantic v2 has three serialization entry points, and they differ in what they hand back to you. model_dump walks the model in the Rust core and builds a tree of Python dicts, lists and scalars. If you then pass that tree to json.dumps, a second encoder walks the same structure again, allocating Python objects that exist only to be thrown away. model_dump_json skips that intermediate tree and writes JSON bytes straight from the core. TypeAdapter.dump_json gives the same one-step path for types that are not models, such as list[Item], dict[str, Price] or a TypedDict.

Method Output Best for
model_dump Python objects Further processing in Python, or non-JSON sinks such as ORMs, msgpack and templating
model_dump_json JSON string HTTP bodies, queues and caches
TypeAdapter.dump_json JSON bytes Collections and other non-model types

model_dump is still the right choice when the next consumer is Python code or a format other than JSON. Converting to JSON and parsing it back to get a dict costs more than model_dump does.

In FastAPI, the response_model and the declared return type decide how a response is checked and encoded. One common source of double work looks like this: you build a model, call model_dump(), and return the dict. FastAPI may then validate that dict against response_model again and serialize it on its own. Return the model itself, or return a Response built from model_dump_json() if you want to skip the framework's handling. How much FastAPI repeats depends on its version, so profile a real endpoint before you change anything.

from fastapi import FastAPI, Response
from pydantic import BaseModel, TypeAdapter

class Item(BaseModel):
    id: int
    name: str
    price: float

app = FastAPI()
# Build adapters once at import time, not per request.
items_adapter = TypeAdapter(list[Item])

def load_items() -> list[Item]:
    return [Item(id=1, name="widget", price=9.5), Item(id=2, name="gadget", price=12.0)]

# Double work: models -> dicts, then FastAPI may re-validate the dicts
# against response_model and serialize them itself.
@app.get("/items/slow", response_model=list[Item])
def items_slow():
    return [item.model_dump() for item in load_items()]

# Return the models and let the return type act as the response model.
@app.get("/items")
def items() -> list[Item]:
    return load_items()

# Skip the framework's handling: JSON bytes straight from the Rust core.
# A Response is passed through as-is, with no validation or encoding.
@app.get("/items/raw")
def items_raw() -> Response:
    body = items_adapter.dump_json(load_items())  # bytes, no dict tree
    return Response(content=body, media_type="application/json")

@app.get("/items/first/raw")
def first_item_raw() -> Response:
    body = load_items()[0].model_dump_json()  # str, one step
    return Response(content=body, media_type="application/json")

# model_dump stays right for Python consumers or non-JSON sinks, e.g.
# row = item.model_dump(); session.execute(insert(items_table).values(**row))
# Profile each variant under real load before choosing one.

A FastAPI app that contrasts the dict-then-revalidate pattern with returning models directly and with returning a Response whose body comes from model_dump_json or TypeAdapter.dump_json.

When should you avoid custom validators and validation shortcuts?

Every field_validator, model_validator and field_serializer is a Python function that pydantic-core has to call from its compiled validation loop. Each call crosses the Rust–Python boundary, builds Python objects for the arguments, and runs at interpreter speed. Before and after validators add one round trip per field per record. Wrap mode costs more. The core hands your function a handler callable, your code calls back into the core, and control bounces across the boundary twice. Errors raised inside also propagate as Python exceptions. A wrap serializer on a field inside a list of 10,000 items means 10,000 of those round trips during model_dump_json().

Many validators only restate rules the core already enforces natively. Range checks belong in Field(ge=..., le=...). Length limits belong in min_length and max_length, and format checks in pattern. Wrap reused rules in an Annotated type, such as a non-negative integer or a bounded string, so the constraint lives in the compiled schema and is shared across models.

Keep a Python validator when the rule is domain logic that the schema cannot express. Examples include cross-field invariants, checksum verification, or a lookup against reference data. In those cases, a named function that reads clearly is worth the per-call cost, provided profiling shows the cost is acceptable.

Validation-skipping shortcuts are a different kind of risk. model_construct() trusts its input completely: it applies no coercion, no type checks and no constraints. That is fine for data you have already validated, such as rows your own code wrote. Using it on request bodies or third-party payloads turns a performance gain into a data-integrity bug.

What are the alternatives to plain unions for polymorphic payloads?

A plain union such as Cat | Dog | Bird forces the validator to guess. In the default smart mode, Pydantic tries the members, prefers an exact type match over a coerced one, and picks the best candidate. The cost grows with the number of members, because a payload may be validated several times before one branch succeeds. When every branch fails, the error report includes a set of errors for each member, so the client has to work out which shape it meant to send.

A discriminated (tagged) union removes the guess. Each member declares a Literal field, for example kind: Literal["cat"], and the union is marked with that field as its discriminator. The validator reads the tag, jumps to the matching model, and validates once. Errors refer only to that model. A missing or unknown tag produces one specific error instead of a list of failed attempts.

Approach Validation work Error clarity Main cost
Plain union (smart mode) Can try several members Errors for every member Ambiguity, slower with many members
Discriminated union One lookup, one branch Errors from the chosen branch only Requires a stable tag field
Separate endpoints One model per route Clear More routes and client logic
Single model with optional fields One pass Weak: invalid combinations pass Cross-field checks move into validators

The catch is the tag. Clients must send it on every request. Adding a required tag to an existing API breaks callers that don't send it, so plan it as a versioned change.

How do you measure Pydantic v2 performance and decide if it is worth the engineering cost?

Start with payloads captured from real traffic, anonymized if needed, and benchmark them with timeit or pytest-benchmark. Build the TypeAdapter or model class outside the timed loop and run a few warm-up iterations first. Otherwise you measure schema construction, which only matters if cold-start time is your problem. Synthetic payloads mislead because cost depends on shape. Nesting depth, string-heavy versus numeric fields, the number of union branches tried and the count of Python validators can each change the result more than any setting you are testing.

Next, run py-spy record against a service under realistic load. The flame graph shows how much time goes to your Python validators and serializers and how much stays inside the compiled core. If most samples sit in your own hooks, switching to strict mode will barely help. Compare p50 and p99 latency, not just throughput. Tail latency often comes from a few large or deeply nested payloads.

Then weigh the gain against the engineering cost. Strict-mode migrations, contract tests for clients and validator rewrites all take time.

Illustrative estimate (assumed numbers, not a benchmark): saved CPU-seconds/day = requests/day × time saved per request Assume 10,000,000 requests/day × 0.2 ms saved = 2,000 CPU-seconds/day, about 33 minutes.

Compare that with a migration estimate of, say, two engineer-weeks. Profiling first keeps that effort on the one or two endpoints where validation actually dominates.

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.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

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.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • 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.