FastAPI generates an OpenAPI specification from your code and serves interactive documentation UIs automatically. You get two built-in doc sites: Swagger UI (interactive “try it out”) and ReDoc (clean, reference-style reading). Your job is to enrich the OpenAPI metadata so the docs become a usable API reference: group endpoints with tags, write summaries and descriptions, document request/response shapes with examples, and explicitly describe error responses.
Where the docs live (and why it matters)
By default, FastAPI exposes:
/openapi.json: the machine-readable OpenAPI schema (what tools and UIs consume)./docs: Swagger UI (best for interactive testing)./redoc: ReDoc (best for reading and sharing as reference).
Everything you add (summaries, examples, response models, parameter descriptions) ends up in /openapi.json, and both UIs render it. When something looks wrong in the UI, the fastest debugging path is to inspect the generated schema at /openapi.json.
Make endpoints discoverable with tags, summaries, and descriptions
Tags are the primary way users navigate your API in both Swagger UI and ReDoc. Summaries appear as the one-line label for an endpoint; descriptions are the longer explanation shown when expanded.
Tagging a feature module
In a feature module (for example, routers/items.py), define a router with a tag and optional tag metadata (shown in Swagger UI and ReDoc):
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
from fastapi import APIRouter, Query, Path, Body, status, HTTPException, Response, Depends, FastAPI, Request, Header, Cookie, Form, File, UploadFile, Security, BackgroundTasks, WebSocket, WebSocketDisconnect, UploadFile, File, Form, Request, Response, Depends, HTTPException, status, Query, Path, Body, Header, Cookiefrom fastapi import APIRouter, Query, Path, Body, status, HTTPException, Responsefrom pydantic import BaseModel, Field, ConfigDictrouter = APIRouter(prefix="/items", tags=["Items"])In your app setup (often in main.py), you can add tag descriptions so the docs read like a table of contents:
from fastapi import FastAPItags_metadata = [ { "name": "Items", "description": "Create, browse, and manage catalog items. Includes filtering, pagination, and lifecycle operations.", }]app = FastAPI(openapi_tags=tags_metadata)app.include_router(items.router)Now every endpoint under that router is grouped under “Items” in both UIs.
Writing summaries and descriptions that read well
Add summary and description on each operation. Keep summaries short and action-oriented; use descriptions for behavior, constraints, and edge cases.
@router.get( "/", summary="List items", description="Returns a paginated list of items. Supports filtering by availability and searching by name.",)def list_items(): ...Response models, documented status codes, and examples
Interactive docs become trustworthy when responses are explicit: what shape is returned, which status codes are possible, and what errors look like.
Define response models that match what you actually return
Even if your internal data contains extra fields, you can shape what the API documents and returns using response models. This makes the schema stable and readable.
class ItemOut(BaseModel): id: int name: str price: float is_available: bool model_config = ConfigDict(json_schema_extra={ "examples": [ {"id": 1, "name": "Coffee mug", "price": 12.5, "is_available": True} ] })Attach it to the route with response_model and set a clear success status code when relevant:
@router.post( "/", response_model=ItemOut, status_code=status.HTTP_201_CREATED, summary="Create an item", description="Creates a new item and returns the created resource.",)def create_item(...): ...Document error responses explicitly with responses
FastAPI documents validation errors automatically (422). For business errors (404, 409, 401, etc.), add them to the OpenAPI schema via the responses parameter. This is crucial for consumers reading ReDoc.
First, define a reusable error model:
class ErrorResponse(BaseModel): code: str = Field(..., examples=["ITEM_NOT_FOUND"]) message: str = Field(..., examples=["Item 123 was not found."]) details: dict | None = Field(default=None, examples=[{"item_id": 123}])Then attach documented errors to an endpoint:
@router.get( "/{item_id}", response_model=ItemOut, summary="Get an item by ID", description="Returns a single item. Use this to fetch details for display or editing.", responses={ 404: { "model": ErrorResponse, "description": "The item does not exist.", "content": { "application/json": { "example": { "code": "ITEM_NOT_FOUND", "message": "Item 123 was not found.", "details": {"item_id": 123}, } } }, }, },)def get_item(item_id: int): ...Note the difference between responses={404: {"model": ...}} (documentation) and raising an exception at runtime (behavior). You should do both: document the error and implement it.
Annotate parameters for clearer docs (Query, Path, Header, Cookie)
Swagger UI and ReDoc display parameter names, types, and constraints. Use FastAPI’s parameter helpers to add descriptions, constraints, and examples so consumers don’t guess.
Query parameters with constraints and examples
@router.get( "/", response_model=list[ItemOut], summary="List items",)def list_items( q: str | None = Query( default=None, min_length=2, max_length=50, description="Search term applied to the item name.", examples={"basic": {"summary": "Simple search", "value": "mug"}}, ), limit: int = Query( default=20, ge=1, le=100, description="Maximum number of items to return.", ), offset: int = Query( default=0, ge=0, description="Number of items to skip before starting to collect the result set.", ), available: bool | None = Query( default=None, description="If set, filters items by availability.", examples={"only_available": {"value": True}}, ),): ...In Swagger UI, these constraints appear in the parameter details, and invalid values will be rejected by validation (and shown as 422). In ReDoc, the parameter section becomes a readable contract.
Path parameters with descriptions
@router.get("/{item_id}", response_model=ItemOut)def get_item( item_id: int = Path( ..., ge=1, description="Numeric identifier of the item.", examples={"example": {"value": 123}}, )): ...Header and cookie parameters (documenting client requirements)
If an endpoint requires a header (for example, an idempotency key) or reads a cookie, documenting it makes integration smoother.
@router.post( "/", response_model=ItemOut, status_code=status.HTTP_201_CREATED, summary="Create an item",)def create_item( idempotency_key: str | None = Header( default=None, alias="Idempotency-Key", description="Optional key to safely retry create requests without duplicating items.", examples={"uuid": {"value": "0f3c2d2a-0b6b-4c2e-9b1a-2f8a0c7a9d11"}}, ), ...): ...Annotate request bodies for better schemas and examples
Request bodies are often the most important part of your docs. You can improve them at two levels:
- Field-level metadata using Pydantic
Field(descriptions, constraints, examples). - Body-level metadata using
Body(...)(multiple examples, descriptions).
Field-level documentation in Pydantic models
class ItemCreate(BaseModel): name: str = Field( ..., min_length=2, max_length=80, description="Human-friendly item name.", examples=["Coffee mug"], ) price: float = Field( ..., gt=0, description="Unit price in the store currency.", examples=[12.5], ) is_available: bool = Field( default=True, description="Whether the item can be ordered.", examples=[True], )Body-level examples (multiple scenarios)
Swagger UI can show multiple request examples. This is helpful when an endpoint supports optional fields or different usage patterns.
@router.post( "/", response_model=ItemOut, status_code=status.HTTP_201_CREATED, summary="Create an item", responses={ 409: { "model": ErrorResponse, "description": "An item with the same name already exists.", } },)def create_item( payload: ItemCreate = Body( ..., description="Item attributes to create.", examples={ "minimal": { "summary": "Minimal create", "value": {"name": "Coffee mug", "price": 12.5, "is_available": True}, }, "unavailable": { "summary": "Create but mark unavailable", "value": {"name": "Limited edition mug", "price": 25.0, "is_available": False}, }, }, )): ...Testing endpoints directly in Swagger UI
Swagger UI is not just documentation; it’s a client. Use it to validate that your docs match reality.
Step-by-step: run a request from Swagger UI
- Open
/docs. - Expand the Items tag group.
- Click an operation (for example,
POST /items/). - Click Try it out.
- Fill in parameters (headers/query/path) and the JSON body using the provided examples.
- Click Execute.
Swagger UI will show:
- Request URL and curl command (useful for copying into scripts).
- Server response status code and body.
- Response headers (useful for pagination headers, caching, correlation IDs, etc.).
What to check while testing
| What you see in Swagger UI | What it should confirm |
|---|---|
| Example request body | Matches your real expected payload; no missing required fields; names are correct. |
| Status code | Success codes match your contract (e.g., 201 on create). |
| Response schema | Only documented fields are returned; types match (string vs number vs boolean). |
| Error responses | Documented errors (404/409) actually occur and match the documented shape. |
Interpreting schemas in Swagger UI and ReDoc
Both UIs render the same OpenAPI schema, but they emphasize different views:
- Swagger UI: shows Schema and Example Value tabs for request/response bodies. Use it to verify the example payloads and try requests.
- ReDoc: presents a more narrative reference with a strong focus on models and field descriptions. Use it to ensure your descriptions read well and your models are understandable.
Reading the schema like an API consumer
- Check which fields are required vs optional.
- Look for constraints: min/max length, numeric bounds, enums.
- Confirm nested models are named and reusable (not anonymous blobs).
- Verify that list endpoints document pagination parameters and limits.
If a schema looks confusing, it usually means the code lacks descriptions/examples or the models are too generic. Improving model and parameter metadata typically fixes the docs immediately.
Polish one feature module so the docs read like a consumable API reference
The goal is that a developer can open /redoc and integrate without asking you questions. Below is a cohesive “Items” module with consistent naming, clear descriptions, examples, and documented error responses.
Step-by-step polishing checklist
- Group endpoints under a single tag (and add tag metadata).
- Summaries start with a verb: “List…”, “Get…”, “Create…”, “Update…”, “Delete…”.
- Descriptions explain behavior, constraints, and notable edge cases.
- Parameters use
Query/Path/Headerwith descriptions and examples. - Request bodies have field descriptions and at least one example.
- Responses include success model and documented error responses with example payloads.
Example: a polished Items router
from fastapi import APIRouter, Body, Path, Query, statusfrom pydantic import BaseModel, Field, ConfigDictrouter = APIRouter(prefix="/items", tags=["Items"])class ErrorResponse(BaseModel): code: str = Field(..., description="Stable, machine-readable error code.", examples=["ITEM_NOT_FOUND"]) message: str = Field(..., description="Human-readable error message.", examples=["Item 123 was not found."]) details: dict | None = Field(default=None, description="Optional structured details for debugging.")class ItemCreate(BaseModel): name: str = Field(..., min_length=2, max_length=80, description="Human-friendly item name.", examples=["Coffee mug"]) price: float = Field(..., gt=0, description="Unit price in the store currency.", examples=[12.5]) is_available: bool = Field(True, description="Whether the item can be ordered.", examples=[True])class ItemOut(BaseModel): id: int = Field(..., description="Item identifier.", examples=[1]) name: str price: float is_available: bool model_config = ConfigDict(json_schema_extra={ "examples": [ {"id": 1, "name": "Coffee mug", "price": 12.5, "is_available": True} ] })@router.get( "/", response_model=list[ItemOut], summary="List items", description="Returns a paginated list of items. Use query parameters to filter and search.",)def list_items( q: str | None = Query(None, min_length=2, max_length=50, description="Search term applied to the item name."), limit: int = Query(20, ge=1, le=100, description="Maximum number of items to return."), offset: int = Query(0, ge=0, description="Number of items to skip before starting to collect the result set."), available: bool | None = Query(None, description="If set, filters items by availability."),): ...@router.get( "/{item_id}", response_model=ItemOut, summary="Get an item by ID", description="Fetch a single item. Returns 404 if the item does not exist.", responses={ 404: { "model": ErrorResponse, "description": "The item does not exist.", "content": { "application/json": { "example": {"code": "ITEM_NOT_FOUND", "message": "Item 123 was not found.", "details": {"item_id": 123}} } }, } },)def get_item( item_id: int = Path(..., ge=1, description="Numeric identifier of the item.", examples={"example": {"value": 123}}),): ...@router.post( "/", response_model=ItemOut, status_code=status.HTTP_201_CREATED, summary="Create an item", description="Creates a new item and returns it. Returns 409 if the name is already used.", responses={ 409: { "model": ErrorResponse, "description": "An item with the same name already exists.", "content": { "application/json": { "example": {"code": "ITEM_NAME_CONFLICT", "message": "An item named 'Coffee mug' already exists."} } }, } },)def create_item( payload: ItemCreate = Body( ..., description="Item attributes to create.", examples={ "standard": {"summary": "Standard item", "value": {"name": "Coffee mug", "price": 12.5, "is_available": True}}, "unavailable": {"summary": "Create as unavailable", "value": {"name": "Limited edition mug", "price": 25.0, "is_available": False}}, }, )): ...When you load /docs, the “Items” section now reads like a curated set of operations with clear inputs, outputs, and failure modes. When you load /redoc, the same module reads like a clean API reference: models are described, fields have meaning, and consumers can understand constraints without reading your code.