Free Ebook cover FastAPI for Beginners: Build a Production-Ready REST API

FastAPI for Beginners: Build a Production-Ready REST API

New course

14 pages

Interactive API Documentation with OpenAPI, Swagger UI, and ReDoc

Capítulo 8

Estimated reading time: 12 minutes

+ Exercise

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 App

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, Cookie
from fastapi import APIRouter, Query, Path, Body, status, HTTPException, Response
from pydantic import BaseModel, Field, ConfigDict
router = 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 FastAPI
tags_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 UIWhat it should confirm
Example request bodyMatches your real expected payload; no missing required fields; names are correct.
Status codeSuccess codes match your contract (e.g., 201 on create).
Response schemaOnly documented fields are returned; types match (string vs number vs boolean).
Error responsesDocumented 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/Header with 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, status
from pydantic import BaseModel, Field, ConfigDict
router = 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.

Now answer the exercise about the content:

When an endpoint’s behavior looks wrong in Swagger UI or ReDoc, what is the fastest way to debug what the docs are showing?

You are right! Congratulations, now go to the next page

You missed! Try again.

Both Swagger UI and ReDoc are generated from the same OpenAPI schema. When something looks wrong in the UI, inspecting /openapi.json is the quickest way to see what the UIs are rendering.

Next chapter

Database Integration with SQLAlchemy or SQLModel in FastAPI

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.