At application startup FastAPI inspects every registered route — its path,
HTTP method, type annotations, Pydantic models, Query/Body/Header
definitions and docstrings — and assembles an OpenAPI 3.x JSON document.
app = FastAPI(title="My API", version="1.0.0")
@app.get("/items/{id}", summary="Fetch an item")
async def get_item(id: int) -> Item:
"""Return a single item by its numeric ID."""
...
# OpenAPI schema available at /openapi.json
The schema drives:
/docs— Swagger UI (interactive browser)/redoc— ReDoc (readable reference)- Client code generators (
orval,openapi-generator)
Rule of thumb: treat the auto-generated schema as the source of truth for your
API contract; add summary, description, responses and tags to keep it accurate.
Both render the same OpenAPI schema, but for different audiences:
| Swagger UI | ReDoc | |
|---|---|---|
| URL | /docs |
/redoc |
| Primary use | Interactive testing (try-it-out) | Readable documentation |
| Layout | Split-pane, request builder | Three-panel, prose-first |
| Authentication | OAuth2/Bearer flow built-in | Read-only |
app = FastAPI(
docs_url="/docs", # default
redoc_url="/redoc", # default
)
Both can be moved or disabled:
app = FastAPI(docs_url=None, redoc_url="/api-docs")
Rule of thumb: expose both; use Swagger UI for development/testing, share ReDoc links for external consumer documentation.
Set docs_url=None and redoc_url=None (and optionally openapi_url=None)
in the FastAPI() constructor. Typically done via an environment flag:
import os
from fastapi import FastAPI
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
app = FastAPI(
docs_url="/docs" if DEBUG else None,
redoc_url="/redoc" if DEBUG else None,
openapi_url="/openapi.json" if DEBUG else None,
)
Setting openapi_url=None prevents schema scraping even if someone guesses the
/docs URL, since the UI needs the schema to render.
Rule of thumb: disable the schema endpoint (openapi_url=None) in production —
leaking your API structure is a security surface even if no credentials are exposed.
Override app.openapi() to intercept and modify the schema dict before it's served:
from fastapi.openapi.utils import get_openapi
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
schema = get_openapi(
title="My API",
version="2.0.0",
description="Full API description with Markdown",
routes=app.routes,
)
# add a custom security scheme
schema["components"]["securitySchemes"] = {
"ApiKey": {"type": "apiKey", "in": "header", "name": "X-API-Key"}
}
app.openapi_schema = schema
return schema
app.openapi = custom_openapi
Rule of thumb: override app.openapi() only for cross-cutting schema changes
(logos, extra security schemes); use per-route responses={} for endpoint-level docs.
Declare an OAuth2PasswordBearer or HTTPBearer security scheme. FastAPI
adds the "Authorize" button to Swagger UI automatically.
from fastapi.security import HTTPBearer
bearer = HTTPBearer()
@app.get("/secure", dependencies=[Depends(bearer)])
async def secure_endpoint():
return {"ok": True}
For a full OAuth2 password flow with the Swagger "Authorize" dialog:
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
@app.get("/me")
async def me(token: str = Depends(oauth2_scheme)):
...
Rule of thumb: using OAuth2PasswordBearer (not just HTTPBearer) gives you
the full username/password login form in Swagger UI — useful for manual testing.
FastAPI generates OpenAPI 3.1.0 schemas by default as of v0.99+
(previously 3.0.x). OpenAPI 3.1 uses JSON Schema 2020-12 for component schemas,
which means it properly supports null types, $ref alongside other keywords, etc.
app = FastAPI()
# GET /openapi.json → {"openapi": "3.1.0", ...}
If you need 3.0.x compatibility (e.g., older code generators):
app = FastAPI(openapi_version="3.0.3")
Rule of thumb: stay on 3.1.0 for new projects; pin to 3.0.x only if your toolchain doesn't support 3.1 yet.
In Pydantic v2, use model_config with json_schema_extra:
from pydantic import BaseModel, ConfigDict
class Item(BaseModel):
model_config = ConfigDict(
json_schema_extra={
"examples": [{"name": "Widget", "price": 9.99}]
}
)
name: str
price: float
For field-level examples, use Field(examples=[...]):
from pydantic import Field
class Item(BaseModel):
name: str = Field(examples=["Widget", "Gadget"])
price: float = Field(gt=0, examples=[9.99])
Rule of thumb: add at least one realistic example per model — Swagger UI's "Try it out" pre-fills request bodies from examples, saving testers time.
Set include_in_schema=False in the route decorator. The endpoint still works
— it just won't appear in /openapi.json or the docs UI.
@app.get("/internal/health", include_in_schema=False)
async def health():
return {"status": "ok"}
Common uses: health-check endpoints, internal debug routes, legacy redirects that you don't want to document publicly.
Rule of thumb: use include_in_schema=False for infra/ops endpoints that aren't
part of the public API contract.
URL prefix versioning (most common):
v1 = APIRouter(prefix="/v1")
v2 = APIRouter(prefix="/v2")
app.include_router(v1)
app.include_router(v2)
Multiple FastAPI apps mounted with Mount:
from starlette.routing import Mount
v1_app = FastAPI()
v2_app = FastAPI()
app = FastAPI()
app.mount("/v1", v1_app)
app.mount("/v2", v2_app)
# Each sub-app has its own /docs
Header versioning (clean URLs, harder to implement):
@app.get("/items")
async def items(accept_version: str = Header(default="v1")):
if accept_version == "v2":
...
URL prefix is recommended for REST APIs because it's cacheable, bookmarkable and obvious in logs.
Rule of thumb: use URL prefix (/v1, /v2) with separate APIRouter instances
per version; mounted sub-apps are the cleanest option when versions diverge significantly.
Add the headers to the responses dict in the route decorator under the status
code's headers key:
@app.get(
"/items",
responses={
200: {
"headers": {
"X-Total-Count": {
"description": "Total number of items",
"schema": {"type": "integer"},
}
}
}
},
)
async def list_items(response: Response):
items = await db.all()
response.headers["X-Total-Count"] = str(len(items))
return items
Rule of thumb: document custom response headers in responses={} so consumers
know to look for them; set them at runtime by injecting Response.
Callbacks document webhooks that your API will call on the consumer's server after a certain event. They're outbound HTTP calls your API makes, documented as if they were inbound routes.
from fastapi import APIRouter
invoicing_callback = APIRouter()
@invoicing_callback.post("{$callback_url}/invoice")
def invoice_notification(body: InvoiceEvent): ...
@app.post("/subscriptions", callbacks=invoicing_callback.routes)
async def create_subscription(subscription: Subscription):
# after payment succeeds, your server will POST to subscription.callback_url
...
Rule of thumb: define callbacks when your API sends webhook notifications — it lets consumers generate typed handlers for the events you'll push to them.
Pass swagger_ui_init_oauth to FastAPI():
app = FastAPI(
swagger_ui_init_oauth={
"clientId": "my-client-id",
"scopes": "openid profile email",
"usePkceWithAuthorizationCodeGrant": True,
}
)
This pre-fills the "Authorize" dialog in Swagger UI so developers don't have to type the client ID on every test session.
For full Swagger UI parameter control:
app = FastAPI(
swagger_ui_parameters={
"deepLinking": True,
"persistAuthorization": True, # keeps auth token across page refreshes
}
)
Rule of thumb: set persistAuthorization: True in development environments so
testers don't lose their JWT token every time they reload Swagger UI.
More Fundamentals interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.