OpenAPI & Interactive Docs

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on 2026-10-02. All examples use Annotated where a parameter needs one, run on Python 3.10 or newer and were executed on Python 3.13 with TestClient (see Testing for the deprecation warning that Starlette 1.7 prints when TestClient is used with httpx). The conditional and protected documentation examples were also run against a Uvicorn server with curl, and the whole API of the first example was fuzzed with Schemathesis. Every FastAPI application is also an OpenAPI document. FastAPI reads the path operations you declare — paths, parameters, request bodies, response models, security dependencies, status codes, docstrings — and assembles an OpenAPI 3.1.0 description from them. That one document then feeds everything else: the interactive documentation (Swagger UI at /docs, ReDoc at /redoc), client and server code generators, API gateways, contract tests and property-based fuzzers. This page covers how the document is built, every knob that shapes it, how to hide, protect, extend and self-host the documentation, and how to turn the document into client libraries and tests.

How the Schema Is Generated

The application object has an openapi() method. The first time it is called, it runs fastapi.openapi.utils.get_openapi() over the application’s routes, stores the result in app.openapi_schema and returns it; later calls return the stored dictionary. At startup FastAPI also registers a route at /openapi.json that returns app.openapi(), and two HTML routes (/docs, /redoc) whose pages fetch that URL in the browser.

flowchart LR D["Your declarations
paths, parameters, models,
dependencies, docstrings"] --> R["APIRoute objects"] R --> G["get_openapi()"] P["Pydantic
JSON Schema 2020-12"] --> G G --> C["app.openapi_schema
(cached dict)"] C --> J["GET /openapi.json"] J --> S["Swagger UI
/docs"] J --> RD["ReDoc
/redoc"] J --> T["Generators, gateways,
contract tests, fuzzers"]

The division of labour matters for knowing where to fix a wrong schema:

Table 1. Where each part of the document comes from
OpenAPI part Comes from Control it with

info, servers, tags, externalDocs

The FastAPI(…​) constructor

title, summary, description, version, contact, license_info, openapi_tags, servers, openapi_external_docs; see Metadata and Docs URLs

paths and operations

Route decorators (@app.get(…​)) and APIRouter

tags, summary, description, operation_id, deprecated, include_in_schema, openapi_extra; see Path Operation Configuration

parameters (path, query, header, cookie)

Function parameters; Path, Query, Header, Cookie

title, description, examples, deprecated, include_in_schema, constraints

requestBody

Body parameters and Pydantic models

Body(openapi_examples=…​), json_schema_extra, openapi_extra

responses

response_model or the return annotation, status_code, responses=

See Additional Responses and Response Models & Return Types

components.schemas

Pydantic models, as JSON Schema 2020-12

Model configuration, Field, Separate Input and Output Schemas

components.securitySchemes and per-operation security

Security dependencies such as OAuth2PasswordBearer

See Security Fundamentals

callbacks, webhooks

callbacks= and app.webhooks

See Callbacks and Webhooks

Here is the smallest useful API of the running example, the Bookshelf API, and the document FastAPI derives from it.

A minimal Bookshelf API
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()


class Book(BaseModel):
    id: int
    title: str
    author: str
    year: int | None = None


BOOKS = [Book(id=1, title="Dune", author="Frank Herbert", year=1965)]


@app.get("/books")
def list_books(limit: int = 10) -> list[Book]:
    return BOOKS[:limit]


@app.get("/books/{book_id}")
def get_book(book_id: int) -> Book:
    return BOOKS[0]
The document served at /openapi.json (top level, abbreviated)
{
  "openapi": "3.1.0",
  "info": {"title": "FastAPI", "version": "0.1.0"},
  "paths": {
    "/books": {
      "get": {
        "summary": "List Books",
        "operationId": "list_books_books_get",
        "parameters": [
          {"name": "limit", "in": "query", "required": false,
           "schema": {"type": "integer", "default": 10, "title": "Limit"}}
        ],
        "responses": {
          "200": {"description": "Successful Response",
                  "content": {"application/json": {"schema": {
                    "type": "array", "items": {"$ref": "#/components/schemas/Book"},
                    "title": "Response List Books Books Get"}}}},
          "422": {"description": "Validation Error",
                  "content": {"application/json": {"schema": {
                    "$ref": "#/components/schemas/HTTPValidationError"}}}}
        }
      }
    }
  },
  "components": {"schemas": {"Book": {"...": "..."}, "HTTPValidationError": {"...": "..."}}}
}

Things to notice, all produced without any configuration:

  • The info block falls back to the title FastAPI and the version 0.1.0; the next section replaces them.

  • The summary List Books is the function name with underscores turned into spaces and each word capitalized.

  • The operationId is <function name>_<path with non-word characters replaced by underscores>_<method>. It is unique, ugly and the origin of the unwieldy method names in generated clients (Generating Clients and Operation IDs).

  • A 422 response with the HTTPValidationError schema is added to every operation that has parameters or a body, because FastAPI validates them. Operations with no input (such as /health in the next sections) get no 422.

  • Book is not inlined: it lives once under components.schemas and is referenced with $ref.

  • The version is 3.1.0. OpenAPI 3.1 aligns with JSON Schema 2020-12, which is why optional fields appear as anyOf with "type": "null" rather than the 3.0-era nullable: true.

The two documentation pages are served as HTML from the same application. Both load their JavaScript and CSS from a CDN by default (see Self-Hosting Swagger UI and ReDoc to change that), and both fetch /openapi.json in the browser:

The three documentation routes, as run with TestClient
GET /openapi.json          200 application/json
GET /docs                  200 text/html; charset=utf-8     (Swagger UI)
GET /redoc                 200 text/html; charset=utf-8     (ReDoc)
GET /docs/oauth2-redirect  200 text/html; charset=utf-8     (helper for OAuth2 login from Swagger UI)

Inputs declared on parameters and models also flow into the document. This second application documents the parameters and a Pydantic model field by field and protects one operation with an OAuth2 scheme:

Parameter, field and security metadata
from typing import Annotated

from fastapi import Depends, FastAPI, Query
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel, Field

app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")


class ReviewIn(BaseModel):
    rating: int = Field(ge=1, le=5, description="Stars from 1 to 5", examples=[4])
    text: str = Field(default="", max_length=2000, description="Optional free text")


@app.get("/books")
def search_books(
    q: Annotated[
        str | None,
        Query(
            title="Search text",
            description="Matches title and author, case-insensitive.",
            examples=["dune"],
            max_length=50,
        ),
    ] = None,
    sort: Annotated[str | None, Query(deprecated=True)] = None,
    debug: Annotated[bool, Query(include_in_schema=False)] = False,
) -> list[str]:
    return []


@app.post("/books/{book_id}/reviews", status_code=201)
def add_review(
    book_id: int,
    review: ReviewIn,
    token: Annotated[str, Depends(oauth2_scheme)],
) -> ReviewIn:
    return review

Running it and reading the document shows how each declaration lands. The q parameter carries the title and description (both on the parameter and inside its schema) and the examples; sort is marked "deprecated": true; debug does not appear at all, although GET /books?debug=true still returns 200; ReviewIn gets the minimum, maximum, maxLength, description and examples keywords; and add_review gets "security": [{"OAuth2PasswordBearer": []}] while components.securitySchemes describes the password flow with "tokenUrl": "token". That is also why Swagger UI shows an Authorize button for the application.

The functions title, description, examples, deprecated and include_in_schema exist on Query, Path, Header, Cookie, Body, Form and Field alike; see Query Parameters, Path Parameters and Request Body & Models for each.

Metadata and Docs URLs

The FastAPI(…​) constructor takes the fields of the OpenAPI info object and a few more. Everything in the table is optional.

Table 2. Constructor parameters that shape the document
Parameter Type Effect

title

str

info.title, also the heading in both UIs. Default FastAPI.

summary

str

info.summary, a one-line summary (OpenAPI 3.1.0, FastAPI 0.99.0).

description

str

info.description. Markdown (CommonMark) is rendered by both UIs.

version

str

info.version: the version of your API, not of OpenAPI. Default 0.1.0.

terms_of_service

str

info.termsOfService; must be a URL.

contact

dict

info.contact with name, url, email.

license_info

dict

info.license with name (required when license_info is set) and either url or identifier. An SPDX identifier such as Apache-2.0 exists since OpenAPI 3.1.0 (FastAPI 0.99.0); identifier and url are mutually exclusive in the specification.

openapi_tags

list[dict]

Top-level tags: one dictionary per tag with name, description and optional externalDocs (description, url). The order of the list is the order shown in the UIs.

openapi_external_docs

dict

Top-level externalDocs with description and url; documented in the API reference of the FastAPI class.

servers

list[dict]

Top-level servers; see below.

openapi_url

str or None

Where the document is served. Default /openapi.json. None (or an empty string) turns off the document and with it both UIs.

docs_url

str or None

Where Swagger UI is served. Default /docs. None disables it.

redoc_url

str or None

Where ReDoc is served. Default /redoc. None disables it.

swagger_ui_oauth2_redirect_url

str or None

The helper page for OAuth2 login from Swagger UI. Default /docs/oauth2-redirect; None removes it (the route then answers 404).

swagger_ui_parameters, swagger_ui_init_oauth

dict

Passed to Swagger UI; see Configuring Swagger UI.

root_path, root_path_in_servers

str, bool

Proxy prefix and whether it is added to servers; see below.

separate_input_output_schemas

bool

See Separate Input and Output Schemas.

generate_unique_id_function

callable

See Path Operation Configuration and Generating Clients and Operation IDs.

The next example fills the whole info block, declares tag metadata with an externalDocs link, moves the document to a versioned URL and puts Swagger UI at /documentation while turning ReDoc off.

Bookshelf API metadata
from fastapi import FastAPI

description = """
The **Bookshelf API** manages books, authors and reviews.

## Books

You can list, search and read books.

## Reviews

Readers can post reviews (_authentication required_).
"""

tags_metadata = [
    {
        "name": "books",
        "description": "Browse and manage the **catalog**.",
    },
    {
        "name": "reviews",
        "description": "Reader reviews, one list per book.",
        "externalDocs": {
            "description": "Review guidelines",
            "url": "https://bookshelf.example.com/guidelines",
        },
    },
]

app = FastAPI(
    title="Bookshelf API",
    summary="A small catalog of books, authors and reviews.",
    description=description,
    version="2.5.0",
    terms_of_service="https://bookshelf.example.com/terms/",
    contact={
        "name": "Bookshelf Support",
        "url": "https://bookshelf.example.com/contact/",
        "email": "support@bookshelf.example.com",
    },
    license_info={"name": "Apache 2.0", "identifier": "Apache-2.0"},
    openapi_tags=tags_metadata,
    openapi_external_docs={
        "description": "Developer portal",
        "url": "https://bookshelf.example.com/developers",
    },
    openapi_url="/api/v1/openapi.json",
    docs_url="/documentation",
    redoc_url=None,
)


@app.get("/books", tags=["books"])
def list_books() -> list[str]:
    return ["Dune"]


@app.get("/books/{book_id}/reviews", tags=["reviews"])
def list_reviews(book_id: int) -> list[str]:
    return []

The generated document (the paths and components parts are omitted here):

The info block and tag metadata served at /api/v1/openapi.json
{
  "openapi": "3.1.0",
  "info": {
    "title": "Bookshelf API",
    "summary": "A small catalog of books, authors and reviews.",
    "description": "\nThe **Bookshelf API** manages books, authors and reviews.\n\n## Books\n...",
    "termsOfService": "https://bookshelf.example.com/terms/",
    "contact": {
      "name": "Bookshelf Support",
      "url": "https://bookshelf.example.com/contact/",
      "email": "support@bookshelf.example.com"
    },
    "license": {"name": "Apache 2.0", "identifier": "Apache-2.0"},
    "version": "2.5.0"
  },
  "tags": [
    {"name": "books", "description": "Browse and manage the **catalog**."},
    {"name": "reviews", "description": "Reader reviews, one list per book.",
     "externalDocs": {"description": "Review guidelines",
                      "url": "https://bookshelf.example.com/guidelines"}}
  ],
  "externalDocs": {"description": "Developer portal",
                   "url": "https://bookshelf.example.com/developers"}
}

Run against that application, /openapi.json, /docs and /redoc answer 404, while /api/v1/openapi.json answers 200 with the document and /documentation serves Swagger UI.

Some behaviour to know:

  • Tags without metadata are fine. You do not have to list every tag in openapi_tags; tags that are used on operations but not listed are shown in the UIs in an order chosen by the tool. A tag listed in openapi_tags but not used by any operation still appears in the document.

  • openapi_url=None is the master switch. Without a document the UIs have nothing to load, so /docs and /redoc are not registered either. Setting only docs_url=None leaves the document and ReDoc in place.

  • servers and root_path. When the application sits behind a proxy that strips a path prefix, set root_path (or run Uvicorn with --root-path). FastAPI then adds {"url": "/api/v1"} to servers in the served document, so "Try it out" in Swagger UI calls the right URL; root_path_in_servers=False stops that. An explicit servers list replaces the automatic entry. Details are in Middleware, CORS & Proxies and Deployment.

  • openapi_prefix is deprecated, replaced by root_path, which follows the ASGI standard.

Path Operation Configuration

Everything about a single operation is configured on the decorator (@app.get, @app.post, …​ and the same on an APIRouter), never on the function. The parameters are passed to the decorator, not to the function.

Table 3. Decorator parameters that shape one operation
Parameter OpenAPI field Notes

tags

tags

A list of strings, or of Enum members to keep names consistent. Groups operations in the UIs and, in generated clients, becomes a service or module per tag.

summary

summary

Default: the function name, title-cased (List Books).

description

description

Default: the function docstring, dedented, interpreted as Markdown.

response_description

the description of the main response

Default Successful Response. OpenAPI requires a description on every response, so FastAPI always fills it.

status_code

key of the main response

See Status Codes & Error Handling.

deprecated

deprecated: true

Keeps the operation working and strikes it through in the UIs.

operation_id

operationId

Must be unique across the document; FastAPI emits a Duplicate Operation ID warning when it is not.

include_in_schema

(operation omitted)

False hides the operation from the document and the UIs; the route still answers requests.

responses

extra keys under responses

See Additional Responses.

callbacks

callbacks

See Callbacks and Webhooks.

openapi_extra

merged into the operation object

Deeply merged; for extensions (x-…​) and custom request bodies. See Custom Request Schemas with openapi_extra.

The next application uses all of them: an Enum for tags, a Markdown docstring with a \f truncation marker, a response_description, an explicit operation_id, a deprecated operation, one hidden operation, an extension through openapi_extra, and a router whose include_router call marks every route deprecated.

Operation-level configuration
from enum import Enum

from fastapi import APIRouter, FastAPI
from pydantic import BaseModel


class Tags(Enum):
    books = "books"
    authors = "authors"


class BookIn(BaseModel):
    title: str
    author: str


app = FastAPI()


@app.post(
    "/books",
    tags=[Tags.books],
    summary="Add a book",
    response_description="The book as stored in the catalog",
    operation_id="add_book",
    status_code=201,
)
def add_book(book: BookIn) -> BookIn:
    """
    Add a book to the catalog.

    - **title**: required, shown in search results
    - **author**: required, the display name of the author

    The call is *idempotent* per title and author.
    \f
    :param book: Internal note for Sphinx; not part of the OpenAPI document.
    """
    return book


@app.get("/books/legacy", tags=[Tags.books], deprecated=True)
def list_books_legacy() -> list[BookIn]:
    return []


@app.get("/health", include_in_schema=False)
def health() -> dict[str, str]:
    return {"status": "ok"}


@app.get("/books/audit", openapi_extra={"x-internal-owner": "catalog-team"})
def audit_books() -> list[str]:
    return []


authors = APIRouter(prefix="/authors", tags=[Tags.authors])


@authors.get("")
def list_authors() -> list[str]:
    return ["Frank Herbert"]


@authors.get("/search", deprecated=False)
def search_authors(q: str) -> list[str]:
    return []


app.include_router(authors, deprecated=True)

The document for POST /books (the other operations are summarised below it):

The generated operation for add_book
"/books": {
  "post": {
    "tags": ["books"],
    "summary": "Add a book",
    "description": "Add a book to the catalog.\n\n- **title**: required, shown in search results\n- **author**: required, the display name of the author\n\nThe call is *idempotent* per title and author.",
    "operationId": "add_book",
    "requestBody": {"required": true, "content": {"application/json": {
      "schema": {"$ref": "#/components/schemas/BookIn"}}}},
    "responses": {
      "201": {"description": "The book as stored in the catalog",
              "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BookIn"}}}},
      "422": {"description": "Validation Error", "content": {"application/json": {"schema": {
        "$ref": "#/components/schemas/HTTPValidationError"}}}}
    }
  }
}

What the run showed for the other operations:

  • GET /books/legacy has "deprecated": true; GET /books/audit has the extra key "x-internal-owner": "catalog-team" next to the generated summary, operationId and responses.

  • GET /health is absent from paths, yet GET /health returns {"status": "ok"}: include_in_schema=False hides, it does not protect (see Conditional OpenAPI).

  • Tags written as Tags.books appear in the document as the plain string "books"; the Enum only keeps the spelling consistent in your code.

  • The line :param book: …​ after \f is missing from description. The form feed cuts the docstring for OpenAPI so that tools that read docstrings for other purposes (Sphinx and friends) can keep the rest.

  • Both operations in the router, GET /authors and GET /authors/search, are deprecated. The route search_authors declares deprecated=False, and it still ends up deprecated: the value from include_router wins, so a router-wide deprecation cannot be undone per route. Use the same call to add tags, prefix, dependencies, responses and include_in_schema to all routes of a router; see Project Structure & Layered Architecture.

Table 4. Defaults worth remembering
Item Default

summary

Function name, underscores to spaces, each word capitalized

operationId

<function name>_<path with non-word characters as underscores>_<method>, for example list_books_books_get

Main response description

Successful Response

Validation response

422 Validation Error, added when the operation has parameters or a body

Model name in components.schemas

The class name; -Input and -Output suffixes when the schemas are split (Separate Input and Output Schemas)

Response schema title

Response <Function Name> <Path> <Method> for a non-model return type such as a list

Custom Request Schemas with openapi_extra

FastAPI documents a request body when a parameter is a Pydantic model or a Body. When the body is something else — raw bytes, a CSV upload, YAML, a vendor media type — you read it yourself from Request and describe it through openapi_extra. The dictionary is deep-merged into the generated operation, so it can add to the document or supply the whole requestBody.

Declaring a CSV body and a YAML body by hand
import csv
import io

import yaml
from fastapi import FastAPI, HTTPException, Request
from pydantic import BaseModel, ValidationError

app = FastAPI()


class BookIn(BaseModel):
    title: str
    author: str


@app.post(
    "/books/import",
    openapi_extra={
        "requestBody": {
            "required": True,
            "content": {
                "text/csv": {
                    "schema": {"type": "string"},
                    "example": "title,author\nDune,Frank Herbert\n",
                }
            },
        }
    },
)
async def import_books(request: Request) -> dict[str, int]:
    raw = (await request.body()).decode()
    rows = list(csv.DictReader(io.StringIO(raw)))
    return {"imported": len(rows)}


@app.post(
    "/books/yaml",
    openapi_extra={
        "requestBody": {
            "required": True,
            "content": {"application/x-yaml": {"schema": BookIn.model_json_schema()}},
        }
    },
)
async def add_book_yaml(request: Request) -> BookIn:
    try:
        data = yaml.safe_load(await request.body())
        return BookIn.model_validate(data)
    except (yaml.YAMLError, ValidationError) as exc:
        raise HTTPException(status_code=422, detail="Invalid book document") from exc

The first operation documents text/csv with a plain string schema and an example; the second reuses BookIn.model_json_schema() so that the same Pydantic model produces the JSON Schema and validates the parsed YAML, even though FastAPI never parses the body. Run with TestClient, a two-row CSV returns {"imported": 2}, a valid YAML body returns the validated book and a YAML body without author returns 422 with Invalid book document.

Two caveats from this approach:

  • FastAPI does no parsing or validation for such a body. If the declared schema and your code drift apart, the docs lie; keep both derived from one Pydantic model, as above.

  • model_json_schema() returns a self-contained schema. For a model with nested models it places them under $defs inside that schema, which is valid JSON Schema but is not hoisted into components.schemas, so generators see a separate anonymous schema. Prefer flat models for such bodies.

For ordinary uploads, Form and UploadFile already generate a multipart/form-data schema; see Forms & File Uploads and Request Object & Content Type.

Request Examples

Examples make the Try it out button useful. Pydantic and FastAPI offer two levels, and both end up in the document:

  • Model level, with json_schema_extra (or Field(examples=[…​]) per field): one or more example values stored in the model’s JSON Schema under examples, shared by every operation that uses the model.

  • Operation level, with openapi_examples on Body, Query, Path, Header, Cookie and Form (FastAPI 0.103.0, 2023-08-26): named examples with summary, description and value (or externalValue) shown in a drop-down in Swagger UI.

Model-level and operation-level examples
from typing import Annotated

from fastapi import Body, FastAPI
from pydantic import BaseModel, ConfigDict

app = FastAPI()


class BookIn(BaseModel):
    title: str
    author: str
    year: int | None = None

    model_config = ConfigDict(
        json_schema_extra={
            "examples": [{"title": "Dune", "author": "Frank Herbert", "year": 1965}]
        }
    )


@app.post("/books")
def add_book(
    book: Annotated[
        BookIn,
        Body(
            openapi_examples={
                "classic": {
                    "summary": "A classic",
                    "description": "Title, author and year.",
                    "value": {"title": "Dune", "author": "Frank Herbert", "year": 1965},
                },
                "minimal": {
                    "summary": "Only required fields",
                    "value": {"title": "Untitled", "author": "Unknown"},
                },
            },
        ),
    ],
) -> BookIn:
    return book

The model’s schema now ends with "examples": [{"author": "Frank Herbert", "title": "Dune", "year": 1965}], and the operation’s requestBody content gets an examples map with the keys classic and minimal, each with its summary and value. Examples are not validated against the model by FastAPI, so a stale example is only caught by a test; see Testing the Schema, and Testing Against It. The tutorial page above lists a few more forms, including the JSON Schema examples keyword on fields.

Additional Responses

status_code and response_model describe the main response. Anything else the operation can answer — 401, 403, 404, 409, a different media type — is declared with the responses parameter: a dictionary keyed by status code (or default, or a range such as 4XX) whose values are OpenAPI Response objects. A value may carry a model key with a Pydantic model; the key is FastAPI’s, not OpenAPI’s, and is replaced with a $ref to a JSON Schema. The other keys are copied as they are, so any key from the Response Object of the specification works (description, headers, content, links).

responses can be set in three places, and the three are merged: on the application (FastAPI(responses=…​)), on a router (APIRouter(responses=…​) or include_router(…​, responses=…​)) and on the decorator. The decorator wins for the same status code.

Declaring extra responses, reusing a shared dictionary and a second media type
from fastapi import APIRouter, FastAPI
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel


class Book(BaseModel):
    id: int
    title: str


class Message(BaseModel):
    message: str


common_responses = {
    401: {"model": Message, "description": "Missing or invalid credentials"},
    403: {"description": "Not enough privileges"},
}

app = FastAPI(responses={500: {"model": Message, "description": "Unexpected error"}})
router = APIRouter(prefix="/books", responses={429: {"description": "Rate limited"}})


@router.get(
    "/{book_id}",
    response_model=Book,
    responses={
        **common_responses,
        404: {"model": Message, "description": "The book was not found"},
        200: {
            "description": "The book",
            "content": {
                "application/json": {"example": {"id": 1, "title": "Dune"}},
            },
        },
    },
)
def get_book(book_id: int):
    if book_id == 1:
        return {"id": 1, "title": "Dune"}
    return JSONResponse(status_code=404, content={"message": "Book not found"})


@router.get(
    "/{book_id}/cover",
    response_class=Response,
    responses={
        200: {"content": {"image/png": {}}, "description": "The cover image"},
        404: {"model": Message},
    },
)
def get_cover(book_id: int):
    return Response(content=b"\x89PNG\r\n\x1a\n", media_type="image/png")


app.include_router(router)

Reading components and paths from the run:

Responses documented for the two operations (status: description, media types)
GET /books/{book_id}
  200  The book                          application/json   (with the example)
  500  Unexpected error                  application/json   (from the application)
  429  Rate limited                      -                  (from the router)
  401  Missing or invalid credentials    application/json   (shared dict)
  403  Not enough privileges             -                  (shared dict)
  404  The book was not found            application/json
  422  Validation Error                  application/json   (added by FastAPI)

GET /books/{book_id}/cover
  200  The cover image                   image/png
  500, 429                               as above
  404  Not Found                        application/json   (model without description)
  422  Validation Error                  application/json

Points to keep in mind:

  • responses documents; it does not produce. Declaring 404 here does not make the function return one. Return a JSONResponse with that status (as get_book does) or raise HTTPException; see Status Codes & Error Handling. The model for the documented status is not used for validation.

  • Combine, do not repeat. The **common_responses unpacking pattern shares predefined responses between operations; the 200 entry above adds a description and an example while keeping the schema from response_model.

  • Media types. Unless you name a different one, the extra responses use the media type of the main response class (application/json by default). With response_class=Response (no media type of its own) the 200 entry above defines image/png, and for a model attached to an extra response FastAPI uses application/json.

  • Defining 422 yourself replaces the generated entry for the operation, which is how you document a custom validation error format; see Status Codes & Error Handling.

  • Use the specification for the rest. The OpenAPI Responses Object and Response Object define what else can go in each value.

Separate Input and Output Schemas

Since Pydantic v2 the same model can have two different JSON Schemas: one for validating input, one for serializing output. FastAPI documents both when they differ, by naming them <Model>-Input and <Model>-Output in components.schemas (FastAPI 0.102.0, 2023-08-25). This helps generated clients: a field with a default is optional when you send the model and always present when you receive it.

Two models, one with a computed field and one with serialization defaults required
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, computed_field

app = FastAPI()  # separate_input_output_schemas=True is the default


class Book(BaseModel):
    title: str
    year: int

    @computed_field
    @property
    def label(self) -> str:
        return f"{self.title} ({self.year})"


class Review(BaseModel):
    model_config = ConfigDict(json_schema_serialization_defaults_required=True)

    rating: int
    tags: list[str] = []


@app.post("/books")
def add_book(book: Book) -> Book:
    return book


@app.post("/reviews")
def add_review(review: Review) -> Review:
    return review


single = FastAPI(separate_input_output_schemas=False)
single.post("/books")(add_book)
single.post("/reviews")(add_review)

The run (FastAPI 0.142.2, Pydantic 2.13.5) produced:

Table 5. Schemas in components for the default application and for separate_input_output_schemas=False
Schema Default application separate_input_output_schemas=False

Book (has a computed_field)

Book-Input (title, year) and Book-Output (title, year, label), all required

Same two schemas: a computed field exists only in the output

Review (json_schema_serialization_defaults_required=True)

Review-Input (tags optional) and Review-Output (tags required)

One schema Review with tags optional

Two points the run made clear. First, a plain default value is not enough on its own to split the schema with the current stack: a model with tags: list[str] = [] and nothing else produced a single schema in a test, because Pydantic only marks defaulted fields as required in serialization mode when json_schema_serialization_defaults_required=True is set. The official how-to page describes the split for a model with a defaulted field; with Pydantic 2.13.5 and FastAPI 0.142.2 the split appears when something differs between the two modes (a computed field, different validation and serialization aliases, or that configuration flag). Second, the switch only merges schemas that differ in required-ness; it cannot hide an output-only field.

If you already ship generated clients that expect the old single schema, separate_input_output_schemas=False keeps the names stable while you migrate; see Where Older Guides Are Outdated, and What Changed Recently.

Callbacks and Webhooks

Both describe requests that go from your API to the client’s server, and both are documentation only: FastAPI neither registers the client’s URL nor sends the request. The difference is who knows the URL.

  • A callback belongs to one operation: the client passes a URL in the request (or body) and your API calls it back later. The URL is an OpenAPI runtime expression that references the incoming request.

  • A webhook is an event your API may send to URLs that clients registered some other way, for instance in a dashboard. It has a name, not a path. Webhooks are an OpenAPI 3.1.0 feature, supported from FastAPI 0.99.0 (2023-06-30).

sequenceDiagram autonumber participant C as Client developer participant A as Bookshelf API participant X as Client server Note over C,A: Callback: the URL travels with the request C->>A: POST /subscriptions?callback_url=https://partner.example.org/events A-->>C: 200 subscribed A->>X: POST callback_url/reviews/sub42 (ReviewEvent) X-->>A: 200 ok Note over C,A: Webhook: the URL was registered elsewhere C->>A: registers an endpoint (dashboard, your own API) A->>X: POST registered URL (new-review event) X-->>A: any 2xx acknowledges
Documenting a callback and a webhook, and sending the callback with httpx
from datetime import datetime

import httpx
from fastapi import APIRouter, BackgroundTasks, FastAPI
from pydantic import BaseModel, HttpUrl

app = FastAPI()


class Subscription(BaseModel):
    id: str
    genre: str


class ReviewEvent(BaseModel):
    book_id: int
    rating: int
    posted_at: datetime


class EventReceived(BaseModel):
    ok: bool


callback_router = APIRouter()


@callback_router.post(
    "{$callback_url}/reviews/{$request.body.id}", response_model=EventReceived
)
def review_notification(body: ReviewEvent):
    pass


async def notify(callback_url: str, subscription_id: str, event: ReviewEvent) -> None:
    async with httpx.AsyncClient() as client:
        await client.post(
            f"{callback_url}/reviews/{subscription_id}",
            content=event.model_dump_json(),
            headers={"Content-Type": "application/json"},
        )


@app.post("/subscriptions", callbacks=callback_router.routes)
def subscribe(
    subscription: Subscription,
    callback_url: HttpUrl,
    background_tasks: BackgroundTasks,
) -> dict[str, str]:
    """Subscribe to new reviews in a genre; reviews are POSTed to the callback URL."""
    event = ReviewEvent(book_id=1, rating=5, posted_at=datetime(2026, 10, 2, 9, 30))
    background_tasks.add_task(notify, str(callback_url).rstrip("/"), subscription.id, event)
    return {"status": "subscribed"}


@app.webhooks.post("new-review")
def new_review(body: ReviewEvent):
    """
    Sent to every registered endpoint when a review is posted. Reply with any
    2xx status within five seconds to acknowledge it.
    """

How it works:

  • Callbacks use a router as a drawing board. callback_router holds one or more path operations that describe the request the client must be ready to receive. The function bodies are never run (pass is enough). You pass callback_router.routes, not the router itself, to callbacks=.

  • The path is a runtime expression. {$callback_url}/reviews/{$request.body.id} is substituted from the incoming request: $callback_url is the query parameter, $request.body.id is the id field of the JSON body (here the subscription id). Swagger UI shows it in a Callbacks section under the operation.

  • Webhooks are declared on app.webhooks. It is an APIRouter, so @app.webhooks.post("new-review") works like a normal path operation; the string is the event name and the docstring becomes the description. The result is a top-level webhooks key in the document, next to paths.

  • Sending is your code. notify() posts the event with httpx; in the run it was replaced by an httpx.MockTransport and the request seen was POST https://partner.example.org/events/reviews/sub42 with the body {"book_id":1,"rating":5,"posted_at":"2026-10-02T09:30:00"}. The background task keeps the HTTP response fast; see Settings, Lifespan & Background Tasks. For delivery guarantees (retries, signing, dead letters) a queue is the right tool, not BackgroundTasks.

  • Operation ids are ugly here. The run produced review_notificationcallback_urlreviews_request_body_idpost for the callback and new_reviewnew_review_post for the webhook, because both derive the id from a "path" that is not a path. Set operation_id or a generate_unique_id_function (Generating Clients and Operation IDs) if clients are generated from them.

  • Secure webhooks yourself. The document cannot express a signature. Document the header that carries it (for instance an HMAC of the body computed with a per-client secret) in the webhook description, and have clients verify it before trusting the payload.

Conditional OpenAPI

Sometimes the documentation should not be public: an internal service, a production deployment, a regulated environment. The route parameters you already know are enough, because they accept values from settings.

Switching the document off through the environment
from fastapi import FastAPI
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    openapi_url: str = "/openapi.json"


settings = Settings()
app = FastAPI(openapi_url=settings.openapi_url)


@app.get("/")
def root() -> dict[str, str]:
    return {"message": "Hello Bookshelf"}

Starting it with an empty OPENAPI_URL (the field name of pydantic-settings is matched with the environment variable, case-insensitively) disables the document and both UIs; the application itself keeps working:

OPENAPI_URL= uvicorn ex09_conditional:app
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/openapi.json   # 404
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/docs           # 404
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/               # 200

Run against a real Uvicorn process the three requests answered 404, 404 and 200.

Hiding the docs is not a security measure. The operations stay reachable, any flaw in them remains, and hiding the UI only makes the API harder for honest developers to learn (and for you to debug). The real controls are strict request and response models, dependencies for permissions, password hashing and OAuth2 scopes; see Security Fundamentals and OAuth2 Scopes & Authorization. The same goes for include_in_schema=False.

If you want the documentation reachable only by people who have credentials — say, a staging environment that partners can read — keep the document and the UI but serve them through your own routes behind a dependency:

Serving the documentation behind HTTP Basic credentials taken from settings
import secrets
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    docs_user: str
    docs_password: str


settings = Settings()
basic = HTTPBasic()


def docs_access(credentials: Annotated[HTTPBasicCredentials, Depends(basic)]) -> None:
    user_ok = secrets.compare_digest(credentials.username.encode(), settings.docs_user.encode())
    password_ok = secrets.compare_digest(credentials.password.encode(), settings.docs_password.encode())
    if not (user_ok and password_ok):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid credentials",
            headers={"WWW-Authenticate": "Basic"},
        )


app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)


@app.get("/books")
def list_books() -> list[str]:
    return ["Dune"]


@app.get("/internal/openapi.json", include_in_schema=False, dependencies=[Depends(docs_access)])
def internal_openapi() -> dict:
    return app.openapi()


@app.get("/internal/docs", include_in_schema=False, dependencies=[Depends(docs_access)])
def internal_docs():
    return get_swagger_ui_html(
        openapi_url="/internal/openapi.json", title=f"{app.title} - Swagger UI"
    )

Setting openapi_url, docs_url and redoc_url to None removes the default routes, yet app.openapi() still builds the document, so the two guarded routes can return it. The credentials come from settings (the environment variables DOCS_USER and DOCS_PASSWORD; the class declares no default, so the application refuses to start without them) and are compared with secrets.compare_digest. With curl:

curl -si http://127.0.0.1:8000/internal/docs
HTTP/1.1 401 Unauthorized
www-authenticate: Basic
content-type: application/json

{"detail":"Not authenticated"}

and with -u docs:<password> the same URL answers 200 with the Swagger UI page. The default routes (/openapi.json, /docs, /redoc) answer 404, and /books stays public. Basic credentials over plain HTTP are readable on the wire: terminate TLS in front of the application; see Deployment. For a real user base use the same OAuth2 machinery as for the API itself.

Extending the Schema with get_openapi

When a declaration cannot express what you need — a vendor extension, a global externalDocs, a change to every operation — generate the document as FastAPI does and edit the dictionary. The process is the one from How the Schema Is Generated: replace app.openapi with a function that builds the document once with get_openapi(), edits it, and caches it in app.openapi_schema.

Adding an x-logo extension for ReDoc and a top-level externalDocs
from fastapi import FastAPI
from fastapi.openapi.utils import get_openapi

app = FastAPI(title="Bookshelf API", version="2.5.0")


@app.get("/books")
def list_books() -> list[str]:
    return ["Dune"]


def custom_openapi() -> dict:
    if app.openapi_schema:
        return app.openapi_schema
    schema = get_openapi(
        title=app.title,
        version=app.version,
        openapi_version=app.openapi_version,
        summary="Catalog of books, authors and reviews",
        description=app.description,
        routes=app.routes,
    )
    schema["info"]["x-logo"] = {"url": "https://bookshelf.example.com/logo.png"}
    schema["externalDocs"] = {
        "description": "Developer portal",
        "url": "https://bookshelf.example.com/developers",
    }
    app.openapi_schema = schema
    return app.openapi_schema


app.openapi = custom_openapi

The get_openapi() parameters are title, version, openapi_version (default 3.1.0), summary, description, routes and the optional webhooks, tags, servers, terms_of_service, contact, license_info, separate_input_output_schemas and external_docs; pass app.routes to collect the registered operations. The official page notes that app.routes is a lower-level route tree, that FastAPI traverses it to find the effective path operations (including those of included routers), and that this changed in release 0.137.0; see Where Older Guides Are Outdated, and What Changed Recently. The run produced:

{
  "openapi": "3.1.0",
  "info": {
    "title": "Bookshelf API",
    "summary": "Catalog of books, authors and reviews",
    "version": "2.5.0",
    "x-logo": {"url": "https://bookshelf.example.com/logo.png"}
  },
  "externalDocs": {"description": "Developer portal",
                   "url": "https://bookshelf.example.com/developers"}
}

Caching is the point of app.openapi_schema: building the document walks every route and every model, so it happens once, and app.openapi() is app.openapi() is True afterwards. The flip side is that the document is frozen after the first call. Anything you change later (a title, a route added at runtime) is not visible until you reset the cache with app.openapi_schema = None; in tests, build a fresh application or reset the cache instead of relying on order. Note that the replacement function passes its own arguments, so metadata given to FastAPI(…​) must be forwarded (the example forwards title, version, openapi_version and description). If you only add keys, call the original method first (schema = FastAPI.openapi(app)) to avoid repeating them.

ReDoc reads vendor extensions such as x-logo from the info object, which is why the page uses it as the example. The ReDoc documentation lists the extensions it understands.

Configuring Swagger UI

swagger_ui_parameters is a dictionary passed straight to Swagger UI’s configuration after FastAPI converts it to JSON; keys use the JavaScript names of Swagger UI’s configuration. FastAPI’s defaults are dom_id, layout, deepLinking (true), showExtensions (true) and showCommonExtensions (true); your dictionary updates them, so {"deepLinking": False} turns deep linking off. swagger_ui_init_oauth is passed to ui.initOAuth(…​) and pre-fills the Authorize dialog.

Customising Swagger UI
from fastapi import FastAPI

app = FastAPI(
    swagger_ui_parameters={
        "docExpansion": "none",
        "defaultModelsExpandDepth": -1,
        "persistAuthorization": True,
        "displayRequestDuration": True,
        "filter": True,
        "operationsSorter": "alpha",
        "syntaxHighlight": {"theme": "obsidian"},
        "deepLinking": False,
    },
    swagger_ui_init_oauth={
        "clientId": "bookshelf-docs",
        "appName": "Bookshelf API docs",
        "usePkceWithAuthorizationCodeGrant": True,
    },
)


@app.get("/books")
def list_books() -> list[str]:
    return ["Dune"]

The relevant part of the HTML served at /docs, as produced by the run:

SwaggerUIBundle({
    url: '/openapi.json',
    "dom_id": "#swagger-ui",
    "layout": "BaseLayout",
    "deepLinking": false,
    "showExtensions": true,
    "showCommonExtensions": true,
    "docExpansion": "none",
    "defaultModelsExpandDepth": -1,
    "persistAuthorization": true,
    "displayRequestDuration": true,
    "filter": true,
    "operationsSorter": "alpha",
    "syntaxHighlight": {"theme": "obsidian"},
    oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect',
    presets: [ SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset ],
})
ui.initOAuth({"clientId": "bookshelf-docs", "appName": "Bookshelf API docs", "usePkceWithAuthorizationCodeGrant": true})
Table 6. Swagger UI parameters used above
Key Effect

docExpansion

"list" (tags expanded), "full" or "none" (everything collapsed)

defaultModelsExpandDepth

How deep the Schemas section expands; -1 hides the section entirely

persistAuthorization

Keeps the entered credentials across page reloads (stored in the browser)

displayRequestDuration

Shows the duration of each Try it out request

filter

Adds a filter box above the operations

operationsSorter

"alpha" or "method", or a JavaScript function (not possible from Python)

syntaxHighlight

False disables highlighting; a dictionary such as {"theme": "obsidian"} selects a theme

deepLinking

Lets you link to a tag or operation with a URL fragment

The full list is in the Swagger UI configuration reference. Some options are JavaScript-only (functions, plugins, and the presets that FastAPI sets itself); they cannot be passed from Python as JSON. For those, write your own /docs route with get_swagger_ui_html() (next section) and add the JavaScript by hand. With persistAuthorization on, remember that tokens end up in the browser’s storage; leave it off on shared machines.

OAuth2 from Swagger UI needs the redirect helper page (/docs/oauth2-redirect by default), which is why the route exists even when the application has no security. Remove it with swagger_ui_oauth2_redirect_url=None only if the docs do not log in through an OAuth2 provider.

Self-Hosting Swagger UI and ReDoc

By default /docs loads swagger-ui-bundle.js and swagger-ui.css from a CDN, and /redoc loads redoc.standalone.js from a CDN plus web fonts from Google Fonts. That fails on an air-gapped network, behind a strict Content Security Policy, offline during development, and in countries where the CDN is unreachable. The fix is to serve the files yourself:

  1. Download swagger-ui-bundle.js, swagger-ui.css and redoc.standalone.js (from the swagger-ui-dist and redoc distributions, pinned to a version you have tested) into a static/ directory.

  2. Mount it with StaticFiles.

  3. Disable the default docs routes (docs_url=None, redoc_url=None) and re-create them with get_swagger_ui_html(), get_redoc_html() and get_swagger_ui_oauth2_redirect_html() from fastapi.openapi.docs, pointing at the local files.

Serving Swagger UI and ReDoc from the application itself
from pathlib import Path

from fastapi import FastAPI
from fastapi.openapi.docs import (
    get_redoc_html,
    get_swagger_ui_html,
    get_swagger_ui_oauth2_redirect_html,
)
from fastapi.staticfiles import StaticFiles

STATIC = Path(__file__).parent / "static"

app = FastAPI(docs_url=None, redoc_url=None)
app.mount("/static", StaticFiles(directory=STATIC), name="static")


@app.get("/docs", include_in_schema=False)
async def swagger_ui_html():
    return get_swagger_ui_html(
        openapi_url=app.openapi_url,
        title=f"{app.title} - Swagger UI",
        oauth2_redirect_url=app.swagger_ui_oauth2_redirect_url,
        swagger_js_url="/static/swagger-ui-bundle.js",
        swagger_css_url="/static/swagger-ui.css",
        swagger_favicon_url="/static/favicon.png",
    )


@app.get(app.swagger_ui_oauth2_redirect_url, include_in_schema=False)
async def swagger_ui_redirect():
    return get_swagger_ui_oauth2_redirect_html()


@app.get("/redoc", include_in_schema=False)
async def redoc_html():
    return get_redoc_html(
        openapi_url=app.openapi_url,
        title=f"{app.title} - ReDoc",
        redoc_js_url="/static/redoc.standalone.js",
        redoc_favicon_url="/static/favicon.png",
        with_google_fonts=False,
    )


@app.get("/books")
def list_books() -> list[str]:
    return ["Dune"]

The test run used placeholder files in static/ and confirmed the wiring: /docs references /static/swagger-ui.css, /static/favicon.png and /static/swagger-ui-bundle.js; /redoc references /static/favicon.png and /static/redoc.standalone.js and no longer mentions fonts.googleapis.com; the static files answer 200; and the /docs/oauth2-redirect helper still works. Replace the placeholders with the real files before deploying.

Details:

  • get_swagger_ui_html() takes openapi_url, title, swagger_js_url, swagger_css_url, swagger_favicon_url, oauth2_redirect_url, init_oauth and swagger_ui_parameters; get_redoc_html() takes openapi_url, title, redoc_js_url, redoc_favicon_url and with_google_fonts. Pass the same swagger_ui_parameters here as you would to FastAPI(…​), because the constructor argument is not applied to your replacement route.

  • with_google_fonts=False is what removes the last external request from ReDoc.

  • A different CDN, instead of self-hosting, is the same recipe with absolute URLs for the three *_url arguments.

  • Keep include_in_schema=False on the replacement routes or they will document themselves.

  • The OAuth2 redirect route (see Configuring Swagger UI) is registered only together with the default /docs, so with docs_url=None the example re-creates it and passes oauth2_redirect_url to the Swagger page.

  • Behind a proxy that strips a prefix, build the URLs from app.openapi_url and root_path rather than hard-coding absolute paths; see Middleware, CORS & Proxies.

Generating Clients and Operation IDs

Because the document is standard OpenAPI 3.1, generators turn it into typed clients (SDKs). The FastAPI documentation walks through TypeScript with Hey API’s openapi-ts, and also names the OpenAPI Generator project for many other languages; any tool you pick must support OpenAPI 3.1.

npx @hey-api/openapi-ts -i http://localhost:8000/openapi.json -o src/client

Tags drive the client’s structure: a books tag becomes a BooksService (or equivalent), an authors tag an AuthorsService. Method names come from the operationId, and the default id (list_books_books_get) yields awkward names such as listBooksBooksGet. FastAPI therefore lets you replace the function that builds the id with generate_unique_id_function, which receives each APIRoute and returns a string.

Operation ids built from the tag and the function name
from fastapi import FastAPI
from fastapi.routing import APIRoute
from pydantic import BaseModel


def unique_id(route: APIRoute) -> str:
    return f"{route.tags[0]}-{route.name}"


app = FastAPI(generate_unique_id_function=unique_id)


class Book(BaseModel):
    title: str


@app.get("/books", tags=["books"])
def list_books() -> list[Book]:
    return []


@app.post("/books", tags=["books"])
def create_book(book: Book) -> Book:
    return book


@app.get("/authors", tags=["authors"])
def list_authors() -> list[str]:
    return []

The run gave these ids:

Path Method operationId

/books

GET

books-list_books

/books

POST

books-create_book

/authors

GET

authors-list_authors

Rules for that function:

  • The id must be unique across the document, so each function name must be unique per tag (and per module, even in different files).

  • route.tags[0] raises IndexError for an untagged route, and it raises at import time (when the decorator runs), not when the schema is built; every route needs a tag, or the function must handle an empty list.

  • A route’s own operation_id wins over the function.

  • The FastAPI documentation says the function also feeds the names of any custom models FastAPI has to create, so regenerate clients after changing it.

  • The function can be set on the application, on an APIRouter and on a single route.

  • After the tag prefix is removed (a short script over openapi.json, as the FastAPI page shows) you get the cleanest method names, listBooks instead of booksListBooks, while keeping unique ids in the document itself.

To generate from a file, for example in CI where no server is running, export the document from the application object. The same helper checks that the committed file is current:

Exporting openapi.json and checking it for drift
import json
import sys
from pathlib import Path

from ex13_clients import app


def main(target: str, check: bool = False) -> int:
    document = json.dumps(app.openapi(), indent=2, sort_keys=True) + "\n"
    path = Path(target)
    if check:
        return 0 if path.exists() and path.read_text() == document else 1
    path.write_text(document)
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1], check="--check" in sys.argv))

python export_openapi.py openapi.json writes a sorted, indented file, and python export_openapi.py openapi.json --check exits with 0 when the file matches the application and 1 after a hand edit or a code change. (The import in the example, ex13_clients, stands for the module that defines your app.) Commit the file, run --check in the pipeline, and regenerate the client when it fails. This turns an accidental API change into a failed build instead of a surprise in production.

Testing the Schema, and Testing Against It

The document is also a test oracle. The first test below checks the document the application serves: version 3.1, unique operation ids and a tag on every operation. It passed with pytest against the application from the previous section.

A pytest check of the served document
from fastapi.testclient import TestClient

from ex13_clients import app

HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}


def operations(schema: dict):
    for path, item in schema["paths"].items():
        for method, operation in item.items():
            if method in HTTP_METHODS:
                yield path, method, operation


def test_openapi_document_is_served_and_tidy() -> None:
    response = TestClient(app).get("/openapi.json")
    assert response.status_code == 200
    schema = response.json()
    assert schema["openapi"].startswith("3.1")

    operation_ids = [op["operationId"] for _, _, op in operations(schema)]
    assert len(operation_ids) == len(set(operation_ids))
    for path, method, operation in operations(schema):
        assert operation.get("tags"), f"{method.upper()} {path} has no tag"

The second use is property-based testing: Schemathesis reads /openapi.json, generates requests with Hypothesis, sends them and checks that every response matches the declared status codes, media types and schemas. With the Bookshelf API from the beginning of the page started with uvicorn:

schemathesis run http://127.0.0.1:8000/openapi.json --max-examples 20

The run (Schemathesis 4.29.0, Hypothesis-driven) tested 2/2 operations and ended with 195 generated, 195 passed and No issues found. A response that violates its schema, an undocumented status code or a 500 on generated input is reported with a reproducible case. This is why the documented responses and examples on this page matter: the more precisely the document describes the API, the more the fuzzer can check. Hypothesis, fixtures and the rest of the testing toolbox are covered in Testing.

Where Older Guides Are Outdated, and What Changed Recently

  • OpenAPI version. Older guides and books show an OpenAPI 3.0.2 document with nullable: true. FastAPI emits OpenAPI 3.1.0 and JSON Schema 2020-12 since 0.99.0 (2023-06-30), so optional fields are anyOf with null, the license can use an SPDX identifier, summary exists on info, and webhooks exist. Generators and gateways must support 3.1.

  • Examples. Older guides show a single example value. The list form examples follows JSON Schema 2020-12 (0.99.0), and the named, UI-friendly openapi_examples arrived in 0.103.0 (2023-08-26).

  • Input and output schemas. Pydantic v2 can produce -Input and -Output variants of a model; FastAPI supports it since 0.102.0 (2023-08-25) with the switch separate_input_output_schemas. Clients generated from older documents show renamed schemas when they regenerate.

  • Routes in get_openapi. FastAPI 0.137.0 (2026-06-14) preserves APIRouter and APIRoute instances and makes the internal route list a tree; the supported way of building a custom document is still routes=app.routes, and get_openapi() traverses the tree. Code that loops over app.routes expecting every element to be a final APIRoute must be revisited; the release notes for 0.137.2 (2026-06-18) mention the added iter_route_contexts(), and letting get_openapi() do the walking avoids the question.

  • Pydantic v1 era advice. schema_extra, Config classes and .schema() are Pydantic v1. Use ConfigDict with json_schema_extra and model_json_schema(); FastAPI itself dropped Pydantic v1 support in 0.126.0 (2025-12-20) and pydantic.v1 in 0.128.0 (2025-12-27).

Table 7. Version history of the OpenAPI features
FastAPI Date Change

0.99.0

2023-06-30

OpenAPI 3.1.0 and JSON Schema 2020-12, webhooks, summary and the license identifier in info, Swagger UI 5, examples as a list.

0.102.0

2023-08-25

separate_input_output_schemas.

0.103.0

2023-08-26

openapi_examples for parameters and bodies.

0.126.0, 0.128.0

2025-12-20, 2025-12-27

Pydantic v1 support, then pydantic.v1, dropped.

0.137.0

2026-06-14

Routers and routes preserved; app.routes becomes a tree. iter_route_contexts() added in 0.137.2 (2026-06-18).

Pitfalls

  • Setting docs_url=None only removes Swagger UI; the document and ReDoc stay. Use openapi_url=None to remove everything, and remember that none of it is access control.

  • include_in_schema=False and a router-wide deprecated=True are one-way switches at the router level: the first hides without protecting, the second cannot be undone per route.

  • Changing title, version or routes after the first call to app.openapi() has no effect until app.openapi_schema = None.

  • Duplicate operation_id values produce a warning and break generators. A generate_unique_id_function that indexes route.tags[0] raises at import time for untagged routes.

  • responses= documents; it does not return anything or validate bodies. Return the JSONResponse or raise the exception yourself.

  • An openapi_extra request body is invisible to FastAPI: no parsing, no validation, no 422. Keep the declared schema and the code in sync, preferably from one Pydantic model.

  • Examples are not checked against the model. Add a test that validates them.

  • Tags in openapi_tags are shown in list order; a typo in a decorator’s tag creates a new, unlisted tag.

  • A plain default does not split input and output schemas with Pydantic 2.13; clients generated from -Input and -Output names break when you toggle the flag or add a computed field later. Decide early and regenerate.

  • The CDN is a runtime dependency of /docs and /redoc: self-host for air-gapped or restricted networks, and pin the versions you self-host.

  • swagger_ui_parameters given to FastAPI(…​) do not reach a hand-written /docs route; pass them to get_swagger_ui_html() as well.

  • persistAuthorization stores credentials in the browser. Do not enable it for production-like data on shared machines.

  • Webhook and callback blocks document a contract; they send nothing and say nothing about signatures. Document the verification scheme and implement delivery with retries elsewhere.

  • An API whose info.version never changes defeats the purpose of exporting and checking openapi.json; bump it with each release.

  • Pin FastAPI (it is still a 0.x library): schema details such as ids, titles and the route tree have changed between minor versions, and generated clients depend on them.