Customizing OpenAPI Schema Generation in FastAPI
Key takeaways:
- The default
operationIdembeds the function name and the path — rename either and SDKs break. responses={404: ...}documents error shapes; FastAPI adds the 422 for you.response_model_exclude_nonefilters the payload but does not change the schema.include_in_schema=Falsekeeps a route live while hiding it from the document.- Override
app.openapi()only for what routes cannot express, and always cache the result.
This guide applies the controls introduced in JSON Schema Customization to the document as a whole. Every fragment below is taken from a real generated openapi.json, not paraphrased.
The Problem This Solves
FastAPI's generated documentation is good enough that most teams never look at the raw document — until someone generates a client from it. Then the defaults start costing money: method names churn between releases, error responses are undocumented so every consumer guesses, internal endpoints leak into a public spec, and there is nowhere to declare the auth scheme that every request actually needs.
None of these are bugs. They are the difference between documentation that renders and a document that can be consumed by a machine.
Why It Happens
FastAPI builds the OpenAPI document by walking app.routes and inspecting each APIRoute — its path, methods, response_model, dependencies and parameters. Pydantic converts the models to JSON Schema, and FastAPI assembles the result into a single dict, hoists model schemas into components/schemas, and replaces them inline with $ref pointers.
Two consequences follow, and they explain most of the surprises.
First, the document is derived from route declarations, not from your handler bodies. A response the handler can return but the route never declared does not exist as far as the document is concerned — which is why undocumented error responses are the norm rather than the exception.
Second, generation happens once and is memoised on app.openapi_schema. The first request to /docs builds the whole thing; every later request returns the cached dict. That is why a custom openapi() must respect the cache, and why mutating the returned dict is the standard way to inject anything the per-route options cannot express.
Prerequisites
- FastAPI 0.139.2 and Pydantic 2.13.4 — the versions that generated every fragment below.
- Response models on your routes. Without them there is nothing to document.
Operation IDs: the Default Is a Trap
Two routes on the same app — one taking the defaults, one explicit:
"""Real generated OpenAPI fragments: default vs customised operation IDs, responses, and overrides."""
@target.get("/users/{user_id}", response_model=UserResponse)
async def get_user_default(user_id: int) -> Any:
"""Default everything: FastAPI derives the operationId from function name and path."""
return {"id": user_id, "email": "ada@example.com"}
@target.get(
"/orders/{order_id}",
response_model=UserResponse,
response_model_exclude_none=True,
operation_id="get_order",
summary="Fetch one order",
tags=["orders"],
responses={404: {"model": ErrorEnvelope, "description": "Order not found."}},
)
async def get_order(order_id: int) -> Any:
return {"id": order_id, "email": "ada@example.com"}
Real output from _verify/output/up-pyd-openapi-customization.txt:
$ GET /operation-ids
200 OK
{
"/users/{user_id}": {
"get": "get_user_default_users__user_id__get"
},
"/orders/{order_id}": {
"get": "get_order"
}
}
get_user_default_users__user_id__get. That is the method name your generated Python or TypeScript client will expose — function name, then path with separators flattened (note the double underscore where { was), then the method.
The problem is not that it is ugly. It is that it is derived from two things that change for unrelated reasons. Rename the handler during a refactor and every consumer's generated client breaks. Move the route from /users/{user_id} to /v2/users/{user_id} and it breaks again. An explicit operation_id decouples the published API from your internal naming, which is the entire point of having a spec.
Documenting the Responses That Actually Happen
The customised route's full fragment:
$ GET /path/order
200 OK
{
"tags": [
"orders"
],
"summary": "Fetch one order",
"operationId": "get_order",
"parameters": [
{
"name": "order_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Order Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponse"
}
}
}
},
"404": {
"description": "Order not found.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
Three things worth reading off this. The 404 came from the responses mapping and points at ErrorEnvelope. The 422 was added automatically because the route has a validated path parameter — FastAPI documents its own validation failures without being asked. And the models are $ref pointers rather than inline copies, so a client generator emits one shared type per model.
If your error bodies are shaped by a global handler rather than per-route models, the 404 entry above is where you declare that shape — see global exception handlers for consistent API responses. Also note that a responses entry is a claim, not an enforcement: nothing checks that your handler actually returns an ErrorEnvelope on a 404.
Where Field Metadata Comes From
The component schema, generated from the Pydantic model:
$ GET /component-schema
200 OK
{
"properties": {
"id": {
"type": "integer",
"title": "Id",
"description": "Surrogate key, stable across renames."
},
"email": {
"type": "string",
"title": "Email"
},
"nickname": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Nickname"
}
},
"type": "object",
"required": [
"id",
"email"
],
"title": "UserResponse",
"examples": [
{
"email": "ada@example.com",
"id": 1
}
]
}
Field(description=...) became the property description, and model_config's json_schema_extra became the model-level examples. The optional field is anyOf: [string, null] — OpenAPI 3.1 spelling, and the single biggest source of noise when diffing a schema across a Pydantic major, as covered in migrating from Pydantic v1 to v2 without breaking APIs.
Now the gotcha. The route sets response_model_exclude_none=True, so nickname is stripped from the response body when it is null — but nickname is still fully documented above, with no indication it might be missing. response_model_exclude_none is a serialization option; it does not reach the schema. A consumer reading this document has no way to know the key can be absent rather than null, which is a meaningfully different thing to handle in a typed client. If a field is genuinely optional in the payload, say so in the model instead of only filtering it at the edge. Field-level control over this is covered in excluding fields per endpoint.
Overriding app.openapi() for What Routes Cannot Express
Servers, a shared security scheme, and top-level metadata have no per-route equivalent. Wrap the generator and mutate its output:
from fastapi.openapi.utils import get_openapi
def custom_openapi() -> dict[str, Any]:
if target.openapi_schema:
return target.openapi_schema
schema = get_openapi(
title=target.title,
version=target.version,
routes=target.routes,
description="Customised at generation time.",
)
schema["servers"] = [{"url": "https://api.example.com/v2"}]
schema["components"]["securitySchemes"] = {
"bearer": {"type": "http", "scheme": "bearer"}
}
schema["security"] = [{"bearer": []}]
target.openapi_schema = schema
return schema
target.openapi = custom_openapi
$ GET /top-level
200 OK
{
"info": {
"title": "Payments API",
"description": "Customised at generation time.",
"version": "2.0.0"
},
"servers": [
{
"url": "https://api.example.com/v2"
}
],
"security": [
{
"bearer": []
}
],
"securitySchemes": {
"bearer": {
"type": "http",
"scheme": "bearer"
}
},
"paths_present": [
"/orders/{order_id}",
"/users/{user_id}"
],
"cached": true
}
cached: true is the assertion that the memoisation works — calling openapi() twice returned the identical object rather than rebuilding. Without the if target.openapi_schema guard and the assignment, FastAPI regenerates the entire document on every hit to /docs, /redoc and /openapi.json.
paths_present lists two paths. The app has three routes; /internal/metrics was declared with include_in_schema=False and is absent from the document while remaining fully callable. That is the correct way to keep operational endpoints out of a public spec — far safer than stripping paths in the override, which silently breaks the moment someone renames one.
And the same customisations rendered by Swagger UI, screenshotted from the running app:

The servers dropdown, the Authorize button and the orders grouping all come from the customisations above. /internal/metrics does not appear. Tag-based grouping is covered further in router tags and OpenAPI grouping.
Verification
Treat the generated document as an artefact to assert on, not something to eyeball:
def test_operation_ids_are_explicit_and_stable(client):
schema = client.get("/openapi.json").json()
for path, ops in schema["paths"].items():
for method, op in ops.items():
assert "operationId" in op
assert not op["operationId"].endswith(f"_{method}"), (
f"{path} {method} uses FastAPI's derived operationId"
)
def test_internal_routes_are_not_published(client):
schema = client.get("/openapi.json").json()
assert not any(p.startswith("/internal") for p in schema["paths"])
The first test catches the default-operationId problem structurally, so a new route added without an explicit ID fails CI rather than shipping a spec change. Beyond that, the highest-value check is committing openapi.json to the repository and regenerating it in CI: any diff in the spec then shows up in code review, where someone can decide whether it was intended.
Trade-offs and When Not To
Explicit operation_id on every route is worth it only if something consumes the spec. If nobody generates clients, you are maintaining a second name for every endpoint for no benefit, and a stale operation_id left behind after a route is repurposed is worse than a derived one. Adopt it when you publish an SDK, not before.
The app.openapi() override should stay small. It runs after generation and mutates a large nested dict by key path, so it is coupled to the document's internal structure with no type checking and no failure until something reads the affected key. Anything expressible per route — tags, summaries, response models, examples — belongs on the route, where it lives next to the code it describes. Reserve the override for genuinely global concerns: servers, security schemes, licence and contact metadata.
Be careful with caching during development, too. Because the schema is memoised on first access, a route registered after something has already hit /openapi.json will not appear until the process restarts. That is rarely an issue under a reloader but bites when routes are registered dynamically at startup or by a plugin.
Finally, response_model_exclude and friends are a blunt instrument for shaping output. Excluding a field the client's generated type declares as required produces a runtime error in a strongly-typed consumer, and the document gives no warning. Separate response models per endpoint are more code and considerably more honest — see nested model serialization.
FAQ
What does FastAPI's default operationId look like?
It concatenates the function name, the path with separators replaced by underscores, and the HTTP method. A get_user_default function on /users/{user_id} becomes get_user_default_users__user_id__get, including a double underscore where the brace was.
Why should I set operation_id explicitly?
Because SDK generators turn the operationId into a method name. The default embeds both the function name and the path, so renaming a handler or moving a route silently renames every generated client method and breaks consumers at compile time.
Does response_model_exclude_none change the documented schema? No. It filters the response body at runtime but the component schema still lists the field. A field can therefore be documented and absent from the payload, which is why nullable output fields are worth marking as not required rather than only excluded.
How do I hide an internal route from the docs?
Pass include_in_schema=False to the path operation. The route stays live and callable; it simply does not appear in the generated document, so a metrics or health endpoint can exist without being published to consumers.
Why must a custom openapi() function cache its result?
Because FastAPI calls app.openapi() on every request to /docs, /redoc and /openapi.json. Without assigning the result to app.openapi_schema, the whole document is regenerated per request, which on a large API is a measurable cost for output that never changes.
Related Reading
- Up to the topic: JSON Schema Customization.
- For richer request and response samples, see examples in the OpenAPI schema, and for polymorphic payloads discriminated unions in OpenAPI.
- Tags and route grouping are covered in router tags and OpenAPI grouping, and path stability in versioning APIs with routers.
- Error shapes you document here should match what your handlers emit: global exception handlers for consistent API responses.
- For trimming response bodies without misleading the schema, see excluding fields per endpoint.