Pydantic Model Serialization Performance in FastAPI

Key takeaways:

  • Profile a representative payload first so you optimize the real bottleneck.
  • Use model_dump_json for single-pass JSON output.
  • Return a trimmed response model so you serialize only needed fields.
  • Reuse a TypeAdapter for non-model shapes instead of rebuilding it.
  • Avoid re-validation by passing trusted models through or using model_construct.

This guide is the serialization-focused companion to Performance Optimization for Models and complements Handling Deeply Nested JSON Models Efficiently.

The Problem This Solves

Serialization quietly becomes a hot-path cost as payloads grow, especially for nested responses returned at high request rates. The good news is that the biggest wins are simple and measurable: emit JSON in one pass, serialize fewer fields, and stop repeating validation.

Prerequisites

  • Pydantic v2 (the Rust core enables single-pass serialization).
  • A representative payload and a way to time it.

Step-by-Step Implementation

1. Profile first

import time


def profile_serialize(model, n: int = 1000) -> float:
    start = time.perf_counter()
    for _ in range(n):
        model.model_dump_json()
    return (time.perf_counter() - start) / n * 1000   # ms per call

2. Single-pass JSON

# Efficient: one pass on the Rust core.
body = order.model_dump_json()

# Avoid on large graphs: builds a dict, then re-encodes in Python.
# body = json.dumps(order.model_dump())

3. Trim with a response model

from pydantic import BaseModel


class OrderListItem(BaseModel):
    id: int
    total: int          # The list view needs two fields, not the whole order.


@router.get("/orders", response_model=list[OrderListItem])
async def orders() -> list[OrderListItem]:
    return await fetch_order_list()

4. Reuse a TypeAdapter and skip re-validation

from pydantic import TypeAdapter

# Built once at module scope; reused for every serialization of this shape.
ORDERS = TypeAdapter(list[OrderListItem])


def dump_orders(items: list[OrderListItem]) -> bytes:
    return ORDERS.dump_json(items)

Edge Cases and Gotchas

  • Computed fields. A computed_field runs on every serialization; cache its input if it is expensive.
  • by_alias everywhere. Aliasing is cheap, but inconsistent alias conventions across nested models produce confusing output; standardize.
  • Premature optimization. Without profiling you may optimize a path that is not the bottleneck; measure first.

Verification

def test_serialization_within_budget(big_order):
    # Guardrail on a representative payload; tune to your SLA.
    assert profile_serialize(big_order, n=200) < 5.0   # ms per call