Customizing OpenAPI Schema Generation in FastAPI
Key takeaways:
- Set
response_modelso documented output matches actual output. - Give each route a stable
operation_idfor predictable generated client methods. - Add response examples and multiple-status
responsesso docs reflect reality. - Trim payloads with
response_model_exclude_noneto keep responses and docs lean. - Override
app.openapi()for global changes the per-route options cannot express.
This guide implements the controls introduced in JSON Schema Customization. It assumes you document from your models rather than by hand.
The Problem This Solves
Auto-generated OpenAPI is a great default, but production APIs need precision: predictable SDK method names, documented error shapes, hidden internal fields, and a shared security scheme. Without these, generated clients churn between releases and consumers guess at error formats.
Prerequisites
- FastAPI with Pydantic v2 response models.
- A client-generation or docs workflow that consumes the OpenAPI document.
Step-by-Step Implementation
1. Declare response models and exclusions
from fastapi import APIRouter
from app.users.schemas import UserResponse
router = APIRouter()
@router.get(
"/users/{user_id}",
response_model=UserResponse,
response_model_exclude_none=True, # Omit null fields from output and docs.
operation_id="get_user", # Stable name for generated clients.
)
async def get_user(user_id: int) -> UserResponse:
return await load_user(user_id)
2. Document multiple response statuses
from app.core.schemas import ErrorEnvelope
@router.get(
"/orders/{order_id}",
response_model=OrderResponse,
responses={
404: {"model": ErrorEnvelope, "description": "Order not found."},
422: {"model": ErrorEnvelope, "description": "Validation failed."},
},
)
async def get_order(order_id: int) -> OrderResponse:
...
3. Override the schema globally
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi
def custom_openapi(app: FastAPI):
def _openapi() -> dict:
if app.openapi_schema:
return app.openapi_schema # Cached: generated once.
schema = get_openapi(title="Payments API", version="2.0.0", routes=app.routes)
schema["info"]["x-logo"] = {"url": "https://example.com/logo.png"}
app.openapi_schema = schema
return schema
return _openapi
app.openapi = custom_openapi(app)
Edge Cases and Gotchas
- Response model vs return type. If
response_modeland the annotated return type disagree,response_modelwins for serialization and docs; keep them aligned to avoid surprise filtering. - Excluding required fields.
response_model_excludecan remove a field the client expects; exclude only genuinely optional output. - Schema caching. A custom
openapi()must cache intoapp.openapi_schema, or it regenerates on every docs request.
Verification
def test_operation_id_is_stable(client):
schema = client.get("/openapi.json").json()
op = schema["paths"]["/users/{user_id}"]["get"]
assert op["operationId"] == "get_user" # Locks the generated client name.
Related Reading
- Up to the topic: JSON Schema Customization.
- Related patterns: Nested Model Serialization for shaping response bodies, and Modular Router Organization for how tags structure the docs.