A precise schema is what client SDKs, API gateways and partner teams are built on, so it deserves the same care as code.
The generated schema is good by default; a few knobs make it excellent.
Extra responses. FastAPI only knows the success response and the 422. Document the rest with responses={404: {"model": Message, "description": "Item not found"}} on a decorator or router, so clients know the error shapes.
Examples. Field(examples=[...]) on model fields, or model_config = {"json_schema_extra": {"examples": [...]}} for a whole model, appear in the JSON Schema. Body(openapi_examples={...}) adds named examples that Swagger UI shows in a dropdown.
Organization. Tag every operation, and describe tags with FastAPI(openapi_tags=[{"name": "items", "description": "..."}]), which also fixes their order. Hide internal endpoints with include_in_schema=False.
Operation ids. Client generators turn each operationId into a method name. The defaults are long (read_item_items__item_id__get), so pass generate_unique_id_function to FastAPI() to build names like items-read_item from the route's tag and function name. They must stay unique.
Input vs output schemas. With Pydantic v2, a model with defaults can produce separate Item-Input and Item-Output schemas, because output fields with defaults are always present. That's accurate; if your tooling dislikes it, FastAPI(separate_input_output_schemas=False) merges them.
Post-processing. For anything else, replace app.openapi with a function that calls fastapi.openapi.utils.get_openapi(...), edits the dict (add a logo, security schemes, vendor extensions) and caches it in app.openapi_schema, so it's built once rather than on every request.
Example
from fastapi import FastAPI
from fastapi.routing import APIRoute
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
def op_id(route: APIRoute) -> str:
tag = route.tags[0] if route.tags else "default" # untagged routes too
return f"{tag}-{route.name}"
app = FastAPI(generate_unique_id_function=op_id,
openapi_tags=[{"name": "items", "description": "Warehouse items"}])
class Item(BaseModel):
name: str = Field(examples=["Fountain pen"])
price: float = Field(gt=0, examples=[12.5])
class Message(BaseModel):
detail: str
@app.get("/items/{item_id}", tags=["items"], responses={404: {"model": Message}})
def read_item(item_id: int) -> Item:
return Item(name="Pen", price=2)
@app.get("/internal/metrics", include_in_schema=False)
def metrics():
return {}
spec = TestClient(app).get("/openapi.json").json()
op = spec["paths"]["/items/{item_id}"]["get"]
print(op["operationId"], sorted(op["responses"])) # items-read_item ['200', '404', '422']
print("/internal/metrics" in spec["paths"]) # FalseRemember
- responses={code: {...}} documents error shapes beyond 200 and 422
- Field examples and Body openapi_examples improve the docs
- generate_unique_id_function gives client SDKs clean method names
- Override app.openapi and cache in app.openapi_schema for anything else
Watch out for
- Overriding app.openapi without caching the result, so the schema is regenerated on every request.