Customizing OpenAPI Schema Generation in FastAPI

Key takeaways:

  • Set response_model so documented output matches actual output.
  • Give each route a stable operation_id for predictable generated client methods.
  • Add response examples and multiple-status responses so docs reflect reality.
  • Trim payloads with response_model_exclude_none to 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_model and the annotated return type disagree, response_model wins for serialization and docs; keep them aligned to avoid surprise filtering.
  • Excluding required fields. response_model_exclude can remove a field the client expects; exclude only genuinely optional output.
  • Schema caching. A custom openapi() must cache into app.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.