How does Pydantic v2 validation work under the hood?

Pydantic v2 is split into two layers. The Python package inspects your models, type hints, Field constraints and validators, then compiles them into a core schema: a nested description of every field, its type, its constraints and the order of checks. That schema is handed to pydantic-core, a library written in Rust, which builds a validator and a serializer from it. Calls such as model_validate cross into Rust once and stay there until a custom Python validator, or a type Rust doesn't handle natively, forces a callback into Python.

That split creates two costs that behave very differently:

  • Schema build happens once per model class, at class creation, or once per TypeAdapter when it is instantiated. It is relatively slow because it does Python-level introspection.
  • Per-call validation runs in Rust against the prebuilt validator and is usually cheap.

Measure them separately. A benchmark that includes class creation inflates per-call numbers. A TypeAdapter created inside a request handler rebuilds its schema on every request, which hides a real bug inside what looks like "slow validation."

Because a single validation pass is cheap, the cost that matters is how many passes you run and how much redundant work each one does.

raw body bytes
   |
   v
[1] request model validation   <- body may be decoded to Python objects first, then validated
   |
   v
handler                        <- [2] code that rebuilds models from already-validated data
   |
   v
[3] response_model validation  <- the returned object is checked again
   |
   v
[4] serialization -> bytes

Points [2] and [3] are where the same data commonly gets validated more than once.

ONE-TIME COST: schema build (once per model class or TypeAdapter instance)
+------------------------+      +-------------+      +--------------------+
| models, type hints,    | ---> | core schema | ---> | pydantic-core      |
| Field(), validators    |      | (nested)    |      | validator and      |
+------------------------+      +-------------+      | serializer (Rust)  |
                                                     +---------+----------+
                                                               | reused
PER-CALL COST: FastAPI request path                            | per call
          raw body bytes
                 |
                 v
+---------------------------------+
| [1] request model validation    |     body decoded, then validated
+----------------+----------------+
                 v
+---------------------------------+
| [2] handler                     |  !! models rebuilt from validated data
+----------------+----------------+
                 v
+---------------------------------+
| [3] response_model validation   |  !! returned object validated again
+----------------+----------------+
                 v
+---------------------------------+
| [4] serialization -> bytes      |
+----------------+----------------+
                 v
          response bytes

!! = the same data can be validated more than once at this point

The core schema and Rust validator are built once per model class or TypeAdapter instance and reused on every request; the FastAPI request path can re-validate the same data in the handler [2] and at response_model [3].

Where does a FastAPI request pay for validation?

validated twice without anyone intending it.

On the way in, FastAPI reads the raw body and hands it to Pydantic to build your request model. Depending on the FastAPI version, the JSON may be decoded into Python dicts and lists first and then validated, rather than parsed and validated in a single pass inside pydantic-core. This step is necessary. It is the boundary where untrusted input becomes typed data.

The response path is where the waste tends to occur. A typical handler builds its result as a model instance, which runs a full validation in the constructor. FastAPI then checks that object against the response field derived from response_model or the return annotation. Depending on the version, that check can involve dumping the instance and validating the result again. Declaring both a return annotation and response_model does not by itself add a pass, because response_model takes precedence. The combination usually means the handler constructs the model to satisfy the type checker, though, and that construction is the duplicate.

You can remove the second check. Return a Response subclass such as JSONResponse directly, or set response_model=None. Either way you also drop the safety net. That check is what catches a handler leaking an extra field, returning None where the schema promises a string, or drifting from the OpenAPI contract your clients rely on. Remove it only on endpoints where you have measured the cost and trust the data's origin.

These internals have changed across FastAPI and Pydantic releases. Re-check them after every upgrade.

from fastapi import FastAPI
from fastapi.responses import JSONResponse
from pydantic import BaseModel

app = FastAPI()


class UserOut(BaseModel):
    id: int
    email: str
    display_name: str


def load_user(user_id: int) -> dict:
    # Stand-in for a DB row; the dict is not validated yet.
    return {"id": user_id, "email": "ada@example.com", "display_name": "Ada"}


# Common pattern: the constructor validates the data once, then FastAPI
# checks the returned object against response_model again.
@app.get("/users/{user_id}", response_model=UserOut)
def get_user(user_id: int) -> UserOut:
    return UserOut(**load_user(user_id))


# Same safety net with less work: return the raw dict and let FastAPI
# run the response_model check once.
@app.get("/v2/users/{user_id}", response_model=UserOut)
def get_user_v2(user_id: int) -> dict:
    return load_user(user_id)


# Opt out of the check: response_model=None plus a JSONResponse skips
# response validation. responses= keeps the OpenAPI docs, but a wrong or
# extra field now reaches clients unchecked.
@app.get("/v3/users/{user_id}", response_model=None,
         responses={200: {"model": UserOut}})
def get_user_v3(user_id: int) -> JSONResponse:
    return JSONResponse(load_user(user_id))

Three versions of the same endpoint: one that validates the response twice, one that validates it once with the schema check kept, and one that skips response validation entirely.

How can you implement faster validation with model_validate_json and TypeAdapter?

The most common slow pattern is two-step parsing: call json.loads on the request body, then pass the resulting dict to model_validate. That builds a complete tree of Python dicts, lists and strings, and pydantic-core then walks that tree a second time to validate it. Model.model_validate_json(raw_bytes) hands the raw bytes to pydantic-core, which parses and validates in a single pass in Rust. The only Python objects it creates are the ones in the final validated result. You skip the throwaway intermediate structure, and with it the allocation and garbage-collection work it would have caused.

For payloads that are not a single model, such as a top-level array or a mapping of IDs to objects, use TypeAdapter. Wrap list[Item] or dict[str, Item] in a TypeAdapter and call its validate_json method. This gives you the same single-pass behavior without creating a wrapper model just to hold a list.

Build each TypeAdapter once, at module level. Constructing one compiles a core schema, and that is the expensive step. If you create the adapter inside a request handler, every request pays that compilation cost again.

Module-level construction has a trade-off: it moves the cost to import time. In serverless functions or autoscaled pods that start often, a large number of adapters and models can noticeably lengthen cold starts. Measure your startup time before and after the change rather than assuming it is free.

These changes are cheap to make. Each one is local to a single call site, it does not change your public schema, and you can review it in minutes. That makes them good first steps before any redesign of your models.

import json

from pydantic import BaseModel, TypeAdapter


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


# Built once at import time: schema compilation happens here, not per request.
# Trade-off: this cost lands on cold start instead of on each request.
ITEM_LIST = TypeAdapter(list[Item])
ITEM_MAP = TypeAdapter(dict[str, Item])


def parse_item_slow(raw: bytes) -> Item:
    # Two passes: json.loads builds a throwaway dict tree, then validation walks it.
    return Item.model_validate(json.loads(raw))


def parse_item(raw: bytes) -> Item:
    # One pass in pydantic-core: parse and validate straight from bytes.
    return Item.model_validate_json(raw)


def parse_items(raw: bytes) -> list[Item]:
    # Top-level JSON array, no wrapper model needed.
    return ITEM_LIST.validate_json(raw)


def parse_item_map(raw: bytes) -> dict[str, Item]:
    # Mapping of IDs to objects, same single-pass behavior.
    return ITEM_MAP.validate_json(raw)


if __name__ == "__main__":
    body = b'[{"id": "sku-123", "name": "Widget", "price": 9.99}]'
    print(parse_items(body))

The example replaces json.loads plus model_validate with single-pass model_validate_json, and uses module-level TypeAdapters to validate a top-level list and a dict of models.

Which model design choices make validation expensive?

Design changes cost more effort, because they live in the schema and often in the public contract. Most validation time is spent in pydantic-core's compiled Rust code, so the main question is how often validation leaves that fast path or does extra work.

Nesting creates a model instance at every level. A list of objects, each with several nested sub-models, means many allocations per request, even when the handler reads only two fields.

Plain unions may try several members before one succeeds. A discriminated union reads a Literal tag and validates only the matching member.

Python validators move control back into the interpreter for each value. Before and wrap validators are the most expensive. They run on raw input, and a wrap validator also calls back into the core handler.

Lax mode tries coercions, such as "42" to int. Strict mode skips them. The speed gain is usually modest, so pick strict or lax mainly for correctness.

Choice Why it costs Cheaper option What you give up
Deep nesting One instance per level Flatter models, or TypedDict for leaf data Methods and attribute access on inner objects
Large plain union Members are tried one after another Discriminated union with a Literal tag A stable tag field that becomes part of the API contract
Before/wrap validators Runs Python code per value Constrained types, after validators Clear error messages, rules that are easy to read and test
Lax coercion Tries conversions Strict mode Clients that send numeric strings start failing
from typing import Annotated, Literal, Union

from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator


class Card(BaseModel):
    kind: Literal["card"]  # stable tag, now part of the API contract
    last4: Annotated[str, StringConstraints(pattern=r"^\d{4}$")]


class Bank(BaseModel):
    kind: Literal["bank"]
    iban: str


# Plain Union[Card, Bank] may try several members. The discriminator
# reads "kind" first and validates only the matching model.
Payment = Annotated[Union[Card, Bank], Field(discriminator="kind")]

# Normalisation done in pydantic-core instead of Python code.
Sku = Annotated[str, StringConstraints(strip_whitespace=True, to_upper=True)]


class SlowOrder(BaseModel):
    sku: str

    # Runs Python code on raw input for every value: off the fast path.
    @field_validator("sku", mode="before")
    @classmethod
    def normalise(cls, v):
        return v.strip().upper() if isinstance(v, str) else v


class Order(BaseModel):
    model_config = ConfigDict(strict=True)  # "2" is rejected for int
    quantity: int
    sku: Sku
    payment: Payment


Order.model_validate_json('{"quantity": 2, "sku": " ab-1 ", "payment": {"kind": "card", "last4": "4242"}}')

A discriminated union keyed on a Literal tag, a constrained string type that replaces a before validator, and strict mode that rejects numeric strings such as "2" for an int field.

When should you skip validation with model_construct, and when should you avoid it?

model_construct creates an instance without running validation. It sets the attributes you pass, fills in defaults for missing fields, and records which fields were set. It does nothing else, which is why it is fast and also why it is risky.

When you skip validation, you lose:

  • Type coercion. A "42" string stays a string, even if the field is annotated int.
  • Constraint checks. Length limits, numeric bounds and patterns are never evaluated.
  • Validators. Field and model validators, before or after, do not run, so any normalization they perform does not happen.
  • Nested model conversion. A nested dict stays a plain dict instead of becoming the sub-model. Code that later reads order.customer.id fails with an AttributeError.

The failure mode is delayed and indirect. Validation would reject bad input at the boundary with an error that names the field. Without it, the bad value travels until something trips over it: arithmetic on a string, a missing attribute deep in business logic, or serialization that writes the wrong type to a client or cache. In the worst case nothing raises, and the corrupted value ends up in a response or another datastore.

Skipping validation is reasonable when all of these hold: the data comes from a source whose schema you control, such as your own database columns or an object that was already validated in the same process; the types match the model exactly; and a profile shows that construction cost matters on that path.

Avoid it for client input, third-party APIs, message queues shared with other teams, and any model whose validators normalize or enforce invariants that downstream code depends on.

What does serialization cost compared to validation?

Validation runs once per request body. Serialization runs once per response object, and on list endpoints that is once per item, per field. Both run in pydantic-core's Rust code, so the choice of method and the amount of Python you add to the dump path matter more than the model itself.

Option Output When to use
model_dump() dict with Python objects (datetime, UUID, Decimal) Passing data to other Python code, ORMs, or templates
model_dump(mode="json") dict containing only JSON-compatible types When a library needs a dict but will encode it to JSON itself
model_dump_json() JSON str, produced in Rust Writing a response body or a message payload
TypeAdapter(list[Item]).dump_json() JSON bytes for the whole collection List endpoints. One call covers every item, with no Python loop.

model_dump() followed by json.dumps does the work twice. It builds a dict tree of Python objects, then walks that tree again in Python. model_dump_json() encodes in a single pass and never creates the intermediate dicts.

Computed fields and custom serializers (@computed_field, @field_serializer, @model_serializer) are Python functions. Each one forces the Rust serializer to call back into the interpreter. The overhead is small for one object and grows linearly with the size of a list.

Illustrative estimate (assumed numbers, not a benchmark): Formula: items × Python callbacks per item × cost per callback. Assume a page returns 1,000 items, each with 3 computed fields, and each callback costs 2 µs. That gives 1,000 × 3 × 2 µs = 6 ms of serialization per response. This cost comes before any encoding work, so it often outweighs the validation of the small query that produced the list.

How do you measure Pydantic v2 performance, and when is it worth optimizing?

Start with a microbenchmark, but feed it payloads captured from production rather than toy dictionaries. Optional fields, nested lists and long strings change the cost a lot. Run timeit or a simple benchmark loop over model_validate and model_validate_json on those payloads, and report per-item time. This isolates Pydantic, which is also its weakness: it says nothing about the rest of the request.

Next, profile a full request under realistic load with a sampling profiler. The flame graph shows validation's share next to socket I/O, database calls, middleware and framework routing. If validation frames are a thin sliver, stop there.

Illustrative estimate

All inputs here are assumed. A response holds 200 items of roughly 2 KB each, validation costs 20 µs per item, and the service handles 500 requests per second. That gives 200 × 20 µs = 4 ms of CPU per request, and 4 ms × 500 = 2 CPU-seconds per second, or about two cores spent on validation alone. Suppose the same request also waits 40 ms on the database. Users mostly feel the database wait, but your capacity bill reflects those two cores.

Decision checklist

  • Optimize large list responses, internal services that fan out to many downstream calls, and batch ingestion jobs. In all three, per-item cost multiplies.
  • Treat validation as noise when network or database latency dominates the profile.
  • Expect microbenchmark gains to shrink end to end. Code tuned without profiling adds complexity and maintenance cost for little or no benefit.
  • Re-measure after every Pydantic or FastAPI upgrade, because the relative costs can shift between releases.

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 route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

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.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • 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.