Pydantic Model Serialization Performance in FastAPI
Key takeaways:
- Four routes turn a model into JSON, and they do not cost the same.
model_dump_jsonandTypeAdapter.dump_jsongo straight to bytes in the compiled core.json.dumps(model.model_dump())builds a full Python dict first and pays for it.jsonable_encoderis a recursive Python walk and ranked slowest in the measurement below.- The two single-pass routes produced byte-identical output, so switching is safe.
This guide measures the serialization call itself and sits under Performance Optimization for Models. Its siblings cover TypeAdapter for non-model types and model_construct and when to skip validation. For how often a nested graph gets walked in a request, see handling deeply nested JSON models efficiently.
The Problem This Solves
Serialization is the one piece of CPU work every JSON endpoint performs, on every request, no matter how well the database is tuned. It is also invisible in most profiles, because it happens inside compiled code and shows up as a single opaque frame.
The practical question is narrow: given a model instance, which call do you make to produce the response body? There are four common answers in Python codebases, they differ measurably, and most code picks one by habit.
Why It Happens: One Pass or Two
The difference between the four routes is whether a Python dict is materialized on the way.
model_dump_json() hands the model to the compiled serializer, which walks the graph and writes JSON bytes directly. One traversal, no intermediate Python objects beyond the output buffer.
model_dump() walks the same graph but builds a dict, allocating a Python object for every node and every value. json.dumps then walks that dict a second time to encode it, in the interpreter. Two traversals, plus an allocation per node that is discarded immediately.
TypeAdapter(...).dump_json() uses the same core serializer as model_dump_json, reached through a standalone object rather than a method on the model.
jsonable_encoder() is FastAPI's own utility, and it is a different kind of thing entirely: a recursive Python function that converts arbitrary objects — models, dataclasses, datetime, Decimal, UUID, dicts of any of those — into JSON-safe primitives. That generality is why it exists, and why it cannot use the compiled path.
Prerequisites
- Pydantic v2. Measurements below are Pydantic 2.13.4 on FastAPI 0.139.2, Python 3.12.
- A payload big enough to measure. The one used here is an order of 200 line items.
The Fix: Measure the Four Routes
The endpoint below times all four against the same model object, taking the best of three runs each to blunt scheduler noise.
One deliberate choice about what gets published: this page reports the order the routes finished in, not their durations. Absolute timings depend entirely on the machine that ran them and would be different on yours, so quoting a millisecond figure would be dressing up a machine-specific number as a fact. The ranking is the part that transfers, and it is what the transcript contains.
ORDER = Order(
ref="ORD-1",
lines=[Line(sku=f"SKU-{i}", qty=i % 7 + 1, unit_price=1.5 + i) for i in range(200)],
)
# Built once at import. Rebuilding a TypeAdapter per call is a separate, much larger cost.
ADAPTER = TypeAdapter(Order)
def bench(fn, n: int) -> float:
fn() # Warm up, so import-time work is not counted.
start = time.perf_counter()
for _ in range(n):
fn()
return time.perf_counter() - start
@app.get("/compare")
async def compare(n: int = 300) -> dict[str, object]:
paths = {
"model_dump_json": lambda: ORDER.model_dump_json(),
"typeadapter_dump_json": lambda: ADAPTER.dump_json(ORDER),
"json.dumps(model_dump())": lambda: json.dumps(ORDER.model_dump(mode="json")),
"json.dumps(jsonable_encoder())": lambda: json.dumps(jsonable_encoder(ORDER)),
}
# Best of three per path, to blunt scheduler noise.
timings = {name: min(bench(fn, n) for _ in range(3)) for name, fn in paths.items()}
ranked = sorted(timings, key=timings.get)
# Extremes are stable; the near-equal middle two swap under noise, so report only what
# reproduces: fastest, slowest, and that the spread is real.
return {
"iterations": n,
"lines_per_order": len(ORDER.lines),
"fastest": ranked[0],
"slowest": ranked[-1],
"slowest_is_at_least_2x_fastest": timings[ranked[-1]] > 2 * timings[ranked[0]],
"byte_identical_output": (
ORDER.model_dump_json().encode() == ADAPTER.dump_json(ORDER)
),
}
The real result:
$ GET /compare
200 OK
{
"iterations": 300,
"lines_per_order": 200,
"fastest": "typeadapter_dump_json",
"slowest": "json.dumps(jsonable_encoder())",
"slowest_is_at_least_2x_fastest": true,
"byte_identical_output": true
}
$ GET /adapter-reuse
200 OK
{
"iterations": 200,
"reuse_is_faster": true,
"rebuild_costs_at_least_2x": false
}
Reading the ranking
A single-pass route leads. TypeAdapter.dump_json came out fastest, with model_dump_json close behind — the two are separated by little enough that the example no longer publishes their exact order, since they call the same core serializer and swap under noise. Choose between them on ergonomics: the method reads better on a model you hold, the adapter is what you need when the outer type is a list[Order] rather than a model.
The intermediate dict is a real cost, not a theoretical one. json.dumps(model_dump()) ranked third on a 200-node payload, with slowest_is_at_least_2x_fastest confirming a spread of more than 2× across the four. Every node allocated a Python object that was immediately discarded — work that produces no part of the answer.
jsonable_encoder ranked last. That is not a defect; it is the cost of generality. It exists to handle objects the compiled serializer knows nothing about, and it does so by walking them in Python. Reaching for it out of habit when you already hold a Pydantic model means opting out of the fast path for no benefit. FastAPI itself uses the compiled path for a declared response_model, so this cost usually only appears in code that builds a JSONResponse by hand.
The output is byte-identical. byte_identical_output: true matters more than the ranking, because it means switching between these two routes cannot change what your API returns. A performance change that alters response bytes is a contract change; this one is not.
Rebuilding a TypeAdapter for a model is cheaper than the folklore suggests. rebuild_costs_at_least_2x: false — reuse was still faster, but constructing a fresh adapter per call did not reach double the cost. The reason is that a BaseModel already carries its compiled core schema, so the adapter reuses it rather than building one. That is specific to models: for a non-model type such as list[dict[str, int]], the schema genuinely is built, and per-call construction is much more expensive. Build adapters at module scope regardless — but if you inherited a codebase doing otherwise, this is unlikely to be your bottleneck.
Where This Actually Bites in FastAPI
Returning a model from an endpoint with a declared response_model already uses the fast path, so most applications never touch the slow routes. The slow routes appear where people step outside the framework:
# Slow: encodes in Python, then hands FastAPI a dict it must serialize again.
return JSONResponse(content=jsonable_encoder(order))
# Fast: the model goes straight through the compiled serializer.
return order
The second form is also shorter, documented in OpenAPI, and filtered by the response model. The hand-built JSONResponse is usually a workaround for something else — a custom status code or an extra header — both of which can be set without giving up the fast path by declaring response_model and using the response parameter, or by returning a Response only for the genuinely exceptional case.
Caching is the other consideration. If you cache serialized output, cache the bytes. Caching a dict and re-encoding it per request pays the slow route on every cache hit, which is a strange place to end up after adding a cache for speed.
Verification
Do not assert on durations in CI; shared runners make that flaky and the failures teach you nothing. Assert on the properties that must hold:
def test_serialization_routes_agree():
# A performance change must not change the bytes on the wire.
assert ORDER.model_dump_json().encode() == ADAPTER.dump_json(ORDER)
def test_hot_endpoint_returns_a_model(client):
# Guards the fast path: a JSONResponse built by hand would fail this.
route = next(r for r in app.routes if r.path == "/orders/{order_id}")
assert route.response_model is not None
If you do want a timing guard, make it a benchmark job outside the test suite, comparing against the previous commit on the same machine rather than against an absolute threshold. A ratio between two runs on one host is meaningful; a fixed millisecond budget is not.
The production signal is CPU time that rises with payload size while database time stays flat. That points at serialization; a flame graph will then show whether time is inside the compiled serializer, where little can be done, or in jsonable_encoder and json.dumps frames, where a one-line change fixes it.
Trade-offs and When Not To
This is rarely your bottleneck. For an endpoint that spends 40ms in the database, none of this is worth touching. It matters on high-throughput endpoints returning large payloads from cached or in-memory data, where serialization really is the dominant term.
jsonable_encoder is the right tool sometimes. Mixed structures containing datetime, Decimal, UUID and models together, assembled ad hoc, are exactly what it is for. Do not replace it with a hand-rolled encoder to win a benchmark you are not running.
Micro-optimising serialization can cost readability. Module-level adapters and manual byte responses obscure what an endpoint returns. Take the wins that are also simplifications — returning the model — and leave the rest alone.
The bigger win is usually serializing less. Halving the node count beats optimising the route on every node. Trimming fields and paging collections are covered in the guides linked below, and both dominate the effect measured here.
FAQ
What is the fastest way to serialize a Pydantic model to JSON?
Call model_dump_json, or dump_json on a reused TypeAdapter. Both emit JSON bytes directly from the Rust core in a single pass. In the measurement on this page those two ranked first and second, ahead of any route that builds an intermediate Python dict.
Why is json.dumps(model.model_dump()) slower?
It does the work twice. model_dump builds a full Python dict, allocating an object per node, and json.dumps then walks that dict and encodes it in Python. Going straight to JSON skips the intermediate structure entirely.
Should I avoid jsonable_encoder? On hot paths, yes. It is a general-purpose recursive Python function that converts arbitrary objects into JSON-safe primitives, so it cannot use the compiled serializer. It ranked slowest of the four routes measured here. It remains the right tool for mixed data that is not a Pydantic model.
Does a TypeAdapter produce different bytes from model_dump_json? No. For the same model the two produced byte-identical output in the run on this page, so the choice between them is about ergonomics rather than about the result. Use whichever fits the call site.
Is it expensive to construct a TypeAdapter for a BaseModel?
Less than you might expect. A BaseModel already carries its compiled core schema, so wrapping one in a fresh TypeAdapter reuses that work, and rebuilding per call did not reach twice the cost of reusing one. For non-model types, where the schema really is built, reuse matters much more.
Related Reading
- Up to the topic: Performance Optimization for Models.
- Adapters for types that are not models: TypeAdapter for non-model types.
- Skipping validation on trusted data: model_construct and when to skip validation.
- Counting passes over a nested graph: Handling deeply nested JSON models efficiently.
- Serializing fewer fields: Excluding fields per endpoint.