Customizing the FastAPI OpenAPI Schema
There are exactly two places you can change what FastAPI publishes at /openapi.json, and picking the wrong one costs you a week of debugging. You either declare the customization on the route and the model, where the framework builds it into the document for you, or you rewrite the finished dictionary inside a custom openapi() callable. This page resolves which change belongs in which layer, and it is part of the Documenting APIs with OpenAPI guide inside Getting Started with Python APIs for Builders.
The commercial stake is concrete. A customer who generates a client from your spec either gets client.create_forecast() and a working X-API-Key header, or gets client.create_forecast_v1_forecasts__post() and a 401 they email you about. The difference is roughly ninety lines of configuration, and it decides whether your integration support load grows with revenue or stays flat.
The Two Layers, and What Belongs in Each
Default to the declarative layer. Anything FastAPI models natively — operation IDs, response codes, examples, tags, exclusion — should live on the decorator or the Pydantic model, because that keeps the document and the runtime behaviour impossible to desynchronise. Reach for a post-processor only when OpenAPI has a field FastAPI does not expose: vendor extensions, component renaming, document-level security defaults, or scrubbing paths out of a published artifact.
| Goal | Declarative | Post-process |
|---|---|---|
| Method name in the SDK | operation_id | no |
| Named request examples | openapi_examples | no |
| Tag order and blurbs | openapi_tags | rarely |
| Vendor extensions | no | yes |
| Rename schema components | no | yes |
| Strip internal paths | partly | yes |
Operation IDs That Read Like an SDK
FastAPI derives an operation ID from the function name, path, and verb unless you intervene. Setting operation_id on every decorator works but rots: someone adds a route in a hurry, forgets the argument, and one ugly method appears in the next generated client. Install a generate_unique_id_function on the application instead, then override per route only when the derived name is wrong.
# app/naming.py
import os
import re
from fastapi.routing import APIRoute
_WORDS = re.compile(r"[^a-z0-9]+")
def camel_operation_id(route: APIRoute) -> str:
"""Turn `create_forecast` into `createForecast`, prefixing by tag if asked."""
words = [w for w in _WORDS.split(route.name.lower()) if w]
if os.getenv("OPENAPI_ID_PREFIX_TAGS", "false").lower() == "true" and route.tags:
tag = _WORDS.split(str(route.tags[0]).lower())[0]
words = [tag, *words]
head, *tail = words
return head + "".join(part.capitalize() for part in tail)
Wire it into the factory once and every router inherits it. Prefixing by tag matters when two areas of your API both expose a list endpoint — most generators namespace by tag anyway, but a couple flatten everything into a single class and collide.
# app/main.py
import os
from fastapi import FastAPI
from app.naming import camel_operation_id
def create_app() -> FastAPI:
app = FastAPI(
title=os.getenv("API_TITLE", "Untitled API"),
version=os.getenv("API_VERSION", "0.0.0"),
generate_unique_id_function=camel_operation_id,
)
return app
Examples, Tags, and Descriptions the Renderers Honour
Three parameters cover almost every documentation need, and builders reach for the wrong one constantly. Use openapi_examples on Body or Query for named request scenarios, Field(examples=[...]) for single field-level values, and the responses mapping for named response examples. A bare example= argument is deprecated and several generators drop it silently.
Tag metadata is separate from tags on routes. The openapi_tags list sets the order of the navigation sidebar and gives each group a Markdown blurb — this is prime real estate for telling a prospect what a group of endpoints costs.
# app/routers/forecasts.py
import os
from typing import Annotated
from fastapi import APIRouter, Body, status
from app.models import Forecast, ForecastRequest, ProblemDetail
router = APIRouter(prefix=os.getenv("API_PREFIX", "/v1"), tags=["Forecasts"])
SAMPLE_RESPONSE = {
"forecast_id": "fc_01HZX9K2",
"sku": "SKU-1043",
"points": [{"period_start": "2026-08-03", "value": 884.2}],
"billable_units": 1,
}
@router.post(
"/forecasts",
response_model=Forecast,
status_code=status.HTTP_201_CREATED,
summary="Create a demand forecast",
responses={
201: {
"content": {
"application/json": {
"examples": {
"typical": {
"summary": "Weekly retail forecast",
"value": SAMPLE_RESPONSE,
}
}
}
}
},
402: {"model": ProblemDetail, "description": "Plan quota exhausted."},
},
)
async def create_forecast(
payload: Annotated[
ForecastRequest,
Body(
openapi_examples={
"weekly": {
"summary": "Twelve weeks of history",
"value": {"sku": "SKU-1043", "horizon": 4, "history": [812, 790, 845]},
},
"sparse": {
"summary": "Intermittent demand",
"value": {"sku": "SKU-9920", "horizon": 8, "history": [0, 0, 3]},
},
}
),
],
) -> Forecast:
return await run_forecast(payload)
Named examples pay for themselves the first afternoon. A visitor who can pick "Intermittent demand" from a dropdown and press execute understands your product in one click; one facing an empty JSON textarea closes the tab. Keep the payloads honest — they must validate against the model, or the try-it-out button returns a 422 and destroys the trust you just built. If your models are loose, tighten them first using the rules in validating JSON with Pydantic v2.
Hiding Internal Routes Without Losing Their Documentation
include_in_schema=False removes a route from every document, including the one your own team reads. That is the wrong trade for admin tooling, replay endpoints, and migration hooks — you want those documented internally and invisible publicly. Build two artifacts from one route table by filtering routes before handing them to get_openapi.
# app/spec.py
import os
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
from fastapi.routing import APIRoute
TAG_METADATA = [
{"name": "Forecasts", "description": "Billed per forecast created."},
{"name": "Usage", "description": "Read consumption and remaining quota. Free."},
]
def _is_public(route: APIRoute) -> bool:
private_prefix = os.getenv("INTERNAL_PATH_PREFIX", "/internal")
private_tag = os.getenv("INTERNAL_TAG", "Internal")
return not route.path.startswith(private_prefix) and private_tag not in (route.tags or [])
def build_schema(app: FastAPI, audience: str) -> dict:
routes = [r for r in app.routes if not isinstance(r, APIRoute) or _is_public(r)]
match audience:
case "public":
selected, title_suffix = routes, ""
case "internal":
selected, title_suffix = app.routes, " (internal)"
case _:
raise ValueError(f"unknown audience: {audience}")
return get_openapi(
title=os.getenv("API_TITLE", "Untitled API") + title_suffix,
version=os.getenv("API_VERSION", "0.0.0"),
routes=selected,
tags=TAG_METADATA,
servers=[{"url": os.getenv("API_PUBLIC_URL", "http://localhost:8000")}],
)
Now build_schema(app, "public") feeds the file you publish and build_schema(app, "internal") feeds the copy your team browses on a private host. The filter is data-driven, so adding an internal route is a matter of tagging it rather than remembering a decorator argument. One caution: filtering hides the path but not always the schemas those paths referenced, because get_openapi prunes components it no longer needs only when nothing else refers to them. Assert on the published document in CI rather than trusting the filter.
Security Schemes a Generated Client Will Actually Send
A generated client sends credentials only when the document declares a security scheme and an operation references it. FastAPI writes components.securitySchemes automatically from any fastapi.security class you use as a dependency, which is the whole reason to use them instead of reading a header manually. Declaring APIKeyHeader gives generators the header name; HTTPBearer gives them the Authorization: Bearer shape; OAuth2AuthorizationCodeBearer gives them the token URL to run a real flow against.
What FastAPI will not do is set a document-level default or mark specific operations as public. Add both in the post-processor, because a spec that requires auth on your health probe sends customers into a debugging loop before they ever get a key.
# app/security_doc.py
import os
PUBLIC_OPERATION_IDS = {"healthLive", "healthReady"}
def apply_security(schema: dict) -> dict:
scheme = os.getenv("API_SECURITY_SCHEME_NAME", "ApiKeyAuth")
schema.setdefault("components", {}).setdefault("securitySchemes", {}).setdefault(
scheme,
{
"type": "apiKey",
"in": "header",
"name": os.getenv("API_KEY_HEADER", "X-API-Key"),
"description": "Issued in the dashboard. Rotate without downtime.",
},
)
schema["security"] = [{scheme: []}]
for path_item in schema.get("paths", {}).values():
for operation in path_item.values():
if operation.get("operationId") in PUBLIC_OPERATION_IDS:
operation["security"] = []
operation.setdefault("x-codeSamples", [])
return schema
An empty security array on an operation is the OpenAPI way to say "this one needs nothing", and every serious generator honours it. The x-codeSamples key is a ReDoc vendor extension where you can inject curl and Python snippets per operation — a post-processing job precisely because FastAPI has no decorator for it. Which token style you actually issue is a separate decision, weighed in JWT vs API keys for Python APIs; if you went the OAuth route, the scheme is generated by the flow described in implementing the OAuth2 authorization code flow in FastAPI.
When to Post-Process, and When to Refuse
Post-process when the field you need has no FastAPI parameter, when you must publish a filtered artifact, or when a marketplace demands a vendor extension. Those are legitimate and permanent needs. Refuse when you are papering over a modelling problem: rewriting a schema in the post-processor so it matches what the endpoint actually returns produces a document that lies the moment someone changes the code, and generated clients fail at runtime rather than at build time.
Stage matters too. Before your first external customer, do everything declaratively and skip the post-processor entirely — you will change route names weekly and a mutation script keyed on operation IDs will break constantly. Once you have paying integrations, a fifty-line post-processor plus a contract test costs nothing per request, since the document builds once per worker and caches on app.openapi_schema. At high traffic the calculation does not change, because the spec should be a static file behind a CDN by then anyway. The only real cost is maintenance: every mutation keyed on a string is a future silent no-op, so assert on the result.
Migrating an Existing API in Five Steps
Do this in one afternoon and ship it behind a version bump. First, commit today's /openapi.json as a baseline so you can diff every subsequent change. Second, add generate_unique_id_function and regenerate — expect every operation ID to change, which is exactly why you do it before customers generate clients rather than after. Third, add openapi_tags and route tags, then reorder the list until the sidebar reads like a product tour. Fourth, tag internal routes and swap in the two-artifact build. Fifth, add a contract test that asserts operation IDs are unique, that public operations carry a security requirement and health does not, and that no path starts with your internal prefix. That test belongs in the same job described in testing Python APIs with pytest, and it is the only thing that stops the next contributor from undoing all of this.
If customers already generate clients from your current document, treat the operation ID rename as a breaking change and follow the notice period in deprecating an API endpoint without breaking customers.
Builder Verdict
The declarative layer wins, and it is not close. Route decorators, Pydantic models, and a single generate_unique_id_function cover roughly ninety percent of what makes a spec sellable, and they cannot drift from runtime behaviour because they define runtime behaviour. Keep the post-processor small and boring: document-level security, public-operation exemptions, vendor extensions, and the public-versus-internal split. That is four responsibilities, fifty lines, and one test file.
Judge the result commercially. A clean document turns an evaluation into an integration in under ten minutes, which is the single cheapest conversion improvement available to a small API business — cheaper than a pricing page rewrite and far cheaper than the support hours a bad spec generates forever. Ship the declarative pass this week, add the post-processor when a real requirement forces it, and never mutate a schema to hide a modelling mistake.
FAQ
Does renaming operation IDs cost me existing customers? It breaks their generated SDKs the next time they regenerate, not their live HTTP calls, so nothing fails overnight. The cost is a support conversation per integrated customer, which is why you renormalise identifiers before your first paying integration and treat any later change as a major version with a notice window.
What does a customized spec cost to serve at a million documentation hits a month? Effectively nothing if you export it to a static file and serve it from object storage or a CDN, and forty to a hundred dollars of egress plus real worker contention if you serve a 470 KB document from your API process. The customization itself is free at runtime because the document builds once per worker and caches.
Can I rotate the API key header name in the spec without breaking clients?
No. The header name is baked into every generated client, so changing X-API-Key silently strips credentials from requests already in production. Accept both headers server-side for a full deprecation window, publish the new one in the scheme description first, and only then flip the documented value.
Is a post-processor risky to maintain as the API grows? Only if it is unasserted. Every mutation keyed on an operation ID or path string becomes a silent no-op the day someone renames the target, so pair each rule with a test that fails when it stops matching. Fifty tested lines are safe; three hundred untested ones will publish a wrong document eventually.
Should the internal spec live in the same repository as the public one? Yes, same repository, different published destination. Both artifacts come from one route table, so they cannot drift, and committing both means a diff shows you precisely when a route crossed from internal to public — which is exactly the review moment where an accidental exposure gets caught.
Related
- Documenting APIs with OpenAPI — the parent guide covering metadata, spec export, and publishing the artifact this page customizes.
- ReDoc vs Swagger UI — decide which renderer displays your tags, examples, and vendor extensions best.
- Setting Up FastAPI — the application factory and router layout every snippet here assumes.
- Handling API Authentication in Python — implement the credentials your security scheme now advertises to generated clients.
- Creating a Developer Portal for Your API — wrap the customized spec in a self-serve signup and key-issuing flow.