How data moves through a FastAPI request

A FastAPI request crosses the validation and serialization boundary at least twice: once on the way in, once on the way out. Each crossing usually involves more than one transformation of the same data.

The inbound path

The server hands FastAPI raw bytes. FastAPI decodes them into Python objects, typically a dict built from the JSON body. It then validates that dict against the parameter's Pydantic model. That is two passes over the payload: decode, then validate and construct. Your handler receives a fully built model instance.

The outbound path

The handler returns something, often another model instance, an ORM object or a dict. If the route declares a response_model or a return annotation, FastAPI treats that type as a contract. The exact steps depend on the FastAPI version, but the return value may be:

  • dumped to plain Python data,
  • validated again against the response model, which also filters out undeclared fields,
  • serialized to JSON-compatible Python objects,
  • encoded to bytes by the response class.

Counting passes

Trace one field from request to response and write down every time it is decoded, validated, dumped or encoded. On a typical route that returns a model, the count often reaches five or six. Validating your own freshly built model a second time adds no safety. It only adds work.

This count is the angle for the rest of the article. Pydantic v2's Rust core makes each individual pass fast. In a well-written endpoint, the cost that is left comes mostly from how many passes run, not from how expensive any one of them is.

 INBOUND (request body)            OUTBOUND (response_model / return type)
 raw request bytes
        v
+---------------------------+
| 1. decode bytes -> dict   |
+---------------------------+
        v
+---------------------------+
| 2. validate -> Model      |
+---------------------------+
        v
+---------------------------+      +---------------------------------+
| handler logic (your code) |----->| 3. dump -> plain Python data    |
+---------------------------+      +---------------------------------+
                                                   v
                                   +---------------------------------+
                                   | 4. re-validate + filter fields  |
                                   +---------------------------------+
                                                   v
                                   +---------------------------------+
                                   | 5. serialize -> JSON types      |
                                   +---------------------------------+
                                                   v
                                   +---------------------------------+
                                   | 6. encode -> response bytes     |
                                   +---------------------------------+

 Steps 3-5 vary by FastAPI version. Step 4 adds no safety when the
 handler returns a model it just built.

Per-request data path in FastAPI: one field can be decoded, validated, dumped, re-validated, serialized and encoded up to six times, so the total cost depends mostly on how many passes run.

Where response_model overhead comes from

The response_model path is where passes multiply without showing up in your handler code. When a route declares a response model, either explicitly or through the return annotation, FastAPI does not trust the object you return. It treats the value as untrusted input. Depending on the FastAPI version, it may first convert a returned model to plain data, then validate that data against the response model, and only then serialize it to JSON. A model you built and validated a few lines earlier can go through validation a second time.

To work out whether this happens on a given route, count what the framework does after your return. There is one validation against the response type and one serialization. If your handler returns an internal model such as UserRecord while the route declares UserPublic, the second validation does real work: it builds a new object and drops the fields that UserPublic does not declare. If the return type matches the response model exactly, that conversion has nothing to change. Whether the framework skips the check entirely varies by version, so profile it on your stack rather than assuming.

Returning a Response object directly, for example one whose body is the output of model_dump_json(), bypasses response validation and serialization completely. Setting response_model=None turns the check off as well. Both shortcuts carry a cost:

  • Field filtering stops. The response model is often the only thing keeping internal IDs, password hashes or tenant keys out of the payload. Without it, every field you serialize is sent to the client.
  • The OpenAPI schema can drift. The documented schema can stop matching what the endpoint actually returns, and generated clients built from it will break.
from fastapi import FastAPI, Response
from pydantic import BaseModel

app = FastAPI()

class UserPublic(BaseModel):
    id: int
    email: str

class UserRecord(UserPublic):
    password_hash: str  # internal only, must never reach the client
    tenant_key: str     # internal only

def load_user(user_id: int) -> UserRecord:
    return UserRecord(id=user_id, email="ada@example.com",
                      password_hash="$argon2id$placeholder", tenant_key="tenant-123")

# 1. Types differ: after `return`, FastAPI validates against UserPublic
#    (building a new object, dropping secrets), then serializes.
#    The extra pass does real, necessary work here.
@app.get("/a/users/{user_id}", response_model=UserPublic)
def get_user_filtered(user_id: int) -> UserRecord:
    return load_user(user_id)

# 2. Response returned directly: no response validation or serialization by
#    FastAPI. Filtering is now our job, so `include` is explicit. `responses`
#    keeps the documented 200 schema aligned with the real body.
@app.get("/b/users/{user_id}", response_model=None,
         responses={200: {"model": UserPublic}})
def get_user_raw(user_id: int) -> Response:
    body = load_user(user_id).model_dump_json(include={"id", "email"})
    return Response(content=body, media_type="application/json")

# Anti-pattern: same shortcut without `include` would send password_hash and
# tenant_key to every caller, and the OpenAPI schema would not show them.

Compares a route where response_model validation filters an internal model against one that returns a Response directly, showing how the bypass removes the extra pass but makes field filtering and OpenAPI accuracy the handler's responsibility.

Faster parsing and dumping: model_validate_json, model_dump_json and TypeAdapter

When the input is already a Python object, such as a dict from a cache or an ORM row read with from_attributes=True, model_validate is the right call. When you hold raw JSON bytes, as with a request body, a queue message or a Redis value, use model_validate_json. It parses and validates in a single pass inside pydantic-core, so no intermediate Python dict is built and then discarded.

Output has the same problem in reverse. model_dump() followed by json.dumps walks the data twice: once to build Python objects and again to encode them. model_dump_json() writes JSON directly from the Rust serializer. FastAPI's default path takes an endpoint's return value, serializes it through the response model, and then hands it to a generic JSON encoder. Exactly how many passes this involves depends on the FastAPI version. Returning a Response that contains model_dump_json() output skips that machinery.

Situation Slower path Faster path
Parse JSON bytes json.loads + model_validate model_validate_json
Emit JSON model_dump + json.dumps model_dump_json
list[Model], TypedDict TypeAdapter built per call Module-level TypeAdapter

A TypeAdapter builds its core schema when it is constructed, and that setup cost is real. Create one at module level and reuse it with validate_json and dump_json. Building one inside a handler repeats the setup on every request.

Pre-serialized responses bypass response_model filtering and validation, which ties the endpoint to framework internals. Add tests that assert the response body and headers so that behavior changes after an upgrade are caught.

from typing import TypedDict
from fastapi import FastAPI, Request, Response
from pydantic import BaseModel, ConfigDict, TypeAdapter

class Item(BaseModel):
    model_config = ConfigDict(from_attributes=True)  # allow ORM rows as input
    id: int
    name: str
    price: float

class Totals(TypedDict):
    count: int
    total: float

# Built once at import time: core schema construction is paid here, not per request.
ITEMS_ADAPTER = TypeAdapter(list[Item])
TOTALS_ADAPTER = TypeAdapter(Totals)

app = FastAPI()

@app.post("/items/bulk")
async def bulk_create(request: Request) -> Response:
    raw = await request.body()
    # Parse and validate raw bytes in pydantic-core; no intermediate list of dicts.
    # Note: ValidationError here is not auto-converted to a 422; handle it explicitly.
    items = ITEMS_ADAPTER.validate_json(raw)
    totals: Totals = {"count": len(items), "total": sum(i.price for i in items)}
    # dump_json returns bytes; returning a Response skips response_model and the encoder.
    return Response(content=TOTALS_ADAPTER.dump_json(totals), media_type="application/json")

@app.get("/items/{item_id}")
async def get_item(item_id: int) -> Response:
    row = load_item_row(item_id)  # ORM object: validate from attributes, not JSON
    item = Item.model_validate(row)
    # One pass from the Rust serializer instead of model_dump() + json.dumps().
    return Response(content=item.model_dump_json(), media_type="application/json")

def load_item_row(item_id: int):
    ...  # placeholder for an ORM query, e.g. session.get(ItemRow, item_id)

A FastAPI app that reuses module-level TypeAdapters, validates request bytes with validate_json, builds a model from an ORM row with model_validate, and returns pre-serialized JSON via dump_json and model_dump_json.

Model design choices that change validation cost

Validation cost depends heavily on the shape of your models. Pydantic compiles each model into a validator in its Rust core, so plain typed fields stay fast. Each feature below either adds work inside that core or forces a call back into the Python interpreter, which costs much more per call.

Feature Where it runs Why it costs
Typed fields and nested models Rust core Each nesting level builds another model instance
Plain union Rust core Members may be tried in turn until one matches
Discriminated union Rust core Reads the tag and validates against one member only
field_validator / model_validator Python interpreter Crosses from Rust to Python once per field or object
computed_field Python interpreter The property runs on every dump

On large lists, per-item Python calls dominate. One validator on a list item model runs once per item. A computed field adds one call per item on every serialization.

Illustrative estimate (assumed numbers, not a benchmark). Use added time = items × Python hooks per item × cost per hook. Assume 10,000 items, 3 hooks, and 2 µs per hook. That gives 10,000 × 3 × 2 µs = 60 ms of extra time per request, before any Rust-side work.

Rules in the model are applied everywhere and are hard to bypass, which keeps them consistent. The trade-off is that this cost is paid on every path, including internal ones that already hold trusted data. For hot list endpoints, express constraints as typed fields or Field arguments where you can, because those run in Rust. Use discriminated unions instead of plain unions. Move expensive cross-field checks to a single pass over the collection in the service layer.

from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field, field_validator

# Slower: a Python validator crosses from Rust into the interpreter once per item
class LineItemSlow(BaseModel):
    sku: str
    qty: int

    @field_validator("qty")
    @classmethod
    def qty_positive(cls, v: int) -> int:
        if v <= 0:
            raise ValueError("qty must be positive")
        return v

# Faster: the same rule as Field constraints, which the Rust core enforces
class LineItem(BaseModel):
    sku: str = Field(min_length=1, max_length=32)
    qty: int = Field(gt=0)

class CardPayment(BaseModel):
    kind: Literal["card"]
    last4: str = Field(pattern=r"^[0-9]{4}$")

class BankPayment(BaseModel):
    kind: Literal["bank"]
    iban: str = Field(min_length=15, max_length=34)

# Discriminated union: read "kind", then validate against one member only
Payment = Annotated[Union[CardPayment, BankPayment], Field(discriminator="kind")]

class Order(BaseModel):
    items: list[LineItem]
    payment: Payment

def ensure_unique_skus(order: Order) -> None:
    # Cross-item rule runs once over the collection in the service layer
    seen: set[str] = set()
    for item in order.items:
        if item.sku in seen:
            raise ValueError(f"duplicate sku: {item.sku}")
        seen.add(item.sku)

The per-item field_validator is replaced with Field constraints that run in the Rust core, a tagged discriminated union selects the payment model, and the cross-item uniqueness check runs as a single pass in the service layer.

Strict mode, lax mode and model_construct: when to relax and when to avoid it

Lax mode is Pydantic's default. It coerces input that is close enough: "42" becomes 42, "true" becomes True. Strict mode rejects anything that is not already the declared type. Choose between them based on correctness first. Strict mode can skip some coercion work, but treat any speedup as a side effect you measure, not a reason to switch.

At client-facing boundaries, lax mode is usually right. Query strings, form data and loosely written clients send numbers as strings, and rejecting them only shifts the cost to your users. For internal paths, such as messages between your own services or rows you have already shaped, strict mode is more predictable. A type mismatch there is a bug, and you want it to fail loudly at the point where it happens.

Approach Validates types Coerces input Safe for client input
Lax (default) Yes Yes Yes
Strict Yes No Yes, but may reject clients
model_construct No No Never

model_construct skips validation entirely. It sets the values you pass and fills in defaults, and that is all. Use it only for data that was already validated, such as an object rebuilt from your own trusted cache. In exchange you lose type guarantees, constraint checks and validators. An invalid object built this way spreads silently and fails later during serialization, in a database write or in another service, far from where the bad value came in.

Tightening an existing API carries its own risk. Enabling strict mode, or replacing a plain union with a discriminated union, can break clients that relied on coercion or on Pydantic matching a union member without an exact tag. Treat either change as a breaking change to your API.

Measure before you optimize

Profile a realistic endpoint under realistic load before changing any models. A sampling profiler such as py-spy can attach to a running worker without code changes and produce a flame graph. In that graph, look for three things: frames that call into pydantic_core for validation and serialization, time spent waiting on the database driver, and time spent writing the response. A sampling profiler works better here than instrumenting individual functions because instrumentation adds overhead to every call, which inflates the cost of the many small calls that validation makes.

Benchmark with payloads that match production. A ten-item fixture hides costs that grow with list length and nesting depth. Sample real response sizes from logs and test at the median and the tail.

Illustrative estimate. These are assumed inputs, not measurements. An endpoint returns 500 items with 20 fields each. The database query takes 15 ms, the network write takes 5 ms, and validation plus serialization take 6 ms. If you halve the serialization cost, you save about 3 ms of roughly 26 ms. That is noticeable, but the query is still the larger target.

CPU time is not the only cost. A large response holds the ORM rows, the model instances and the encoded bytes in memory at the same time. Under concurrency, that peak is multiplied by the number of in-flight requests. If profiling shows that serialization dominates and tuning has stopped paying off, the payload is probably too big. Add pagination or stream the response, so that no single request has to build the whole result in memory.

Alternatives, who should bother, and the impact on engineering cost

Most teams should leave the defaults alone. If an endpoint returns a handful of small objects and its latency is dominated by a database query or a downstream call, Pydantic's share is noise, and tuning it adds complexity with nothing visible to show for it. The teams that benefit fall into two groups. The first runs high-throughput services where CPU time per request determines how many instances they pay for. The second serves large lists or deeply nested documents, where validation and serialization cost grows with payload size.

Option Latency gain Safety risk Maintenance cost Upgrade fragility
Keep defaults None Lowest Lowest Lowest
Remove redundant passes Moderate, larger on big payloads Low, because data is still validated once Low Low
Bypass validation (model_construct) High per call High, because malformed data can reach clients Medium, because every caller must guarantee trusted input Medium, because it depends on construction behaviour the library may change
Pre-serialize responses High on hot read paths Medium, because the bytes sent can drift from the documented schema Medium to high, because of cache invalidation Medium, because of how response classes interact with OpenAPI generation
Paginate or stream Highest for very large responses Low Medium, because clients must change Low, because it changes the API contract rather than depending on library internals

Profiling first saves engineering time because it caps what any change can achieve. If a custom validator accounts for 2% of request time, rewriting it can save at most 2%, and that ceiling is visible before anyone opens an editor. A profile also tells you which row of the table applies. A before-and-after comparison gives reviewers a concrete reason to accept a less obvious design. After a library upgrade, re-running the same profile shows whether a workaround is still worth keeping or can be removed.

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 shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.