Filter concepts by levelShowing all levels.

Django · Section 55

OpenAPI and API Documentation

Level
advanced
Read
26 min
Concepts
3

OpenAPI is a machine-readable description of an HTTP API — paths, methods, request bodies, response bodies, and security requirements in one JSON or YAML document — and because it is machine-readable, one file drives documentation, generated client libraries, gateway-level request validation, and contract tests. In DRF it is generated rather than written: a generator walks the URLconf and reads each view's serializer. That makes it a correctness tool instead of a chore, since a gap in the document is a gap in the code. What generation cannot do is invent what you never declared — a custom `@action`'s response, a dynamic `get_serializer_class()`, and every error shape live in Python control flow that no static pass can read, so they are absent until annotated with `@extend_schema`. The document then feeds two renderers: Swagger UI, which sends real requests from the browser and is therefore a working API client that needs gating in production, and ReDoc, which is read-only and stays readable across a hundred endpoints. Two more things need declaring by hand — the security scheme, because "this view uses `JWTAuthentication`" does not tell a generator to send `Authorization: Bearer`, and examples, because a schema says what is possible while an example says what is typical, and typical is what people copy. Each API version gets its own document. On tooling: `drf-spectacular` is the current default, with `--fail-on-warn` turning a guessed schema into a failing build; `drf-yasg` is the previous generation, limited to OpenAPI 2.0 by the specification itself, and common enough that you will meet it.

What is true here

  1. The schema is generated from the URLconf and serializers, so it describes what shipped rather than what was intended.
  2. Custom actions, dynamic serializers, and all error responses are invisible to the generator until annotated.
  3. Swagger UI executes real requests and needs gating; ReDoc is read-only and better for reading a large API.
  4. The security scheme and realistic examples must be declared by hand — no generator can infer either.
  5. drf-spectacular targets OpenAPI 3.x; drf-yasg is limited to OpenAPI 2.0 by the specification, not by configuration.

What you will be able to do

  • Generate an accurate OpenAPI document from an existing DRF project
  • Recognise which parts of an API a generator cannot see, and annotate them
  • Serve Swagger UI and ReDoc safely, with authentication documented so both work
  • Write examples that make an endpoint usable rather than merely described
  • Choose between drf-spectacular and drf-yasg, and judge whether a migration is worth it
One generated document, and everything downstream of it
what itcannot infereach warningnames a gap

URLconf + serializers

the source of truth — already serving the requests

@extend_schema annotations

custom actions, error shapes, dynamic serializers

Schema generator

spectacular --fail-on-warn

openapi.yaml

committed, and diffed in CI

Swagger UI

interactive — gate it in production

ReDoc

read-only reference

Generated clients

TypeScript, Kotlin, Go — from the same file

Gateway validation

rejects non-conforming requests before Django

  • URLconf + serializers — the source of truth — already serving the requests
    • leads to Schema generator
  • @extend_schema annotations — custom actions, error shapes, dynamic serializers
    • leads to Schema generator (what it cannot infer)
  • Schema generator — spectacular --fail-on-warn
    • leads to openapi.yaml
    • on error, leads to @extend_schema annotations (each warning names a gap)
  • openapi.yaml — committed, and diffed in CI
    • leads to Swagger UI
    • leads to ReDoc
    • leads to Generated clients
    • leads to Gateway validation
  • Swagger UI — interactive — gate it in production
  • ReDoc — read-only reference
  • Generated clients — TypeScript, Kotlin, Go — from the same file
  • Gateway validation — rejects non-conforming requests before Django

The document itself

What OpenAPI describes, how DRF generates it, and where the generator has to guess.

OpenAPI, schema generation, and request/response schemas

coreintermediate

OpenAPI is a machine-readable description of an HTTP API — every path, every method, the shape of each request body, the shape of each response, and the authentication each endpoint expects — written as a single JSON or YAML document. Because it is machine-readable, one file drives several things at once: interactive documentation, generated client libraries in a dozen languages, request validation in a gateway, and contract tests. In DRF you do not write it by hand. A generator walks your URLconf, reads each view's serializer, and produces the document — which is why the schema is only as accurate as the serializers and the type hints it was derived from.

Think of it as

The reason to generate rather than write is that a hand-maintained document describes the API you *meant* to build, and it starts drifting on the first merge. A generated one describes the API you actually shipped, and its errors are visible: a missing response type in the document means a missing declaration in the code, so fixing the document fixes the code too. That makes generation a correctness tool rather than a documentation chore. What it cannot do is invent information you never gave it. A generator reads serializers well and views poorly, because a view's behaviour lives in Python control flow that no static pass can summarise — so a hand-written `Response({...})`, a dynamic `get_serializer_class()`, or an error shape produced by an exception handler are all invisible unless you annotate them. That is the actual work in this area: not running the generator, but noticing where it had to guess. And once the document exists, treat it as a build artifact rather than a page — check it into the repository, diff it in CI, and a breaking change becomes a reviewable line in a pull request instead of something a client discovers.

python
REST_FRAMEWORK = {"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema"}
SPECTACULAR_SETTINGS = {"TITLE": "Orders API", "VERSION": "1.0.0"}

What we're doing: Generate a schema that describes the endpoints accurately — including the custom action and the error shapes the generator cannot see.

config/settings.py + orders/views.py + config/urls.pypython
REST_FRAMEWORK = {"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema"}
SPECTACULAR_SETTINGS = {
    "TITLE": "Orders API",
    "VERSION": "1.0.0",
    "SERVE_INCLUDE_SCHEMA": False,      # do not document the schema endpoint itself
}


class ErrorSerializer(serializers.Serializer):
    type = serializers.CharField()
    detail = serializers.CharField()
    correlation_id = serializers.CharField()


class OrderViewSet(viewsets.ModelViewSet):
    serializer_class = OrderSerializer          # request + response, inferred
    queryset = Order.objects.all()

    @extend_schema(
        summary="Cancel an order",
        request=CancelSerializer,
        responses={200: OrderSerializer, 409: ErrorSerializer, 403: ErrorSerializer},
    )
    @action(detail=True, methods=["post"])
    def cancel(self, request, pk=None):
        ...


urlpatterns = [
    path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
]
1
One setting switches the whole project onto the generator. Every existing view is described from its serializer without further work.
5
Excluding the schema endpoint from its own document — otherwise generated clients acquire a method for downloading their own definition.
9–12
Declaring the error envelope as a serializer lets every endpoint reference the same schema component, so the contract is stated once and reused.
16
The only line needed for standard CRUD: `serializer_class` supplies both the request and the response schema for all five actions.
19–23
The annotation the generator genuinely cannot infer. Without it the action appears with no request body and no documented responses at all.

Why this works: Everything derivable is derived, and the two things that are not — the custom action and the error shapes — are declared once next to the code they describe, so they move when it does.

Maintaining API documentation as a separate hand-written page

Wrong

text
docs/api.md
  ## POST /orders/
  Body: {"items": [...], "coupon": "..."}
  Returns: 200 {"id": 1, "total": "40.00"}

(The serializer added "currency" and made "coupon" read-only four months ago.)

Better

bash
python manage.py spectacular --file openapi.yaml
git diff --exit-code openapi.yaml    # CI fails if the shipped schema changed unannounced

What you see: A client integrates against the documented shape and gets a 400 for a field the docs say is required and the serializer says is read-only. Nobody can say when the two diverged, because nothing ever compared them.

Why: A hand-written page has no mechanism that forces it to change when the code does, so its accuracy decays silently from the first merge onward. A generated schema derives from the same serializers that serve the requests, so it cannot describe a shape the API does not have — and committing it makes any change to that shape a visible diff someone has to approve.

From your code to a document everything else can consume

1 · The generator walks the URLconf

Every routed view becomes a path and a set of operations. Nothing here is written by hand, so nothing here can drift.

2 · Serializers become schemas

Field types, required-ness, choices, and read-only flags all transfer. A vague serializer produces a vague schema.

3 · You annotate what it cannot infer

Custom actions, error shapes, and dynamic serializer selection are Python control flow — invisible to a static pass until declared.

4 · One document, four consumers

Swagger UI and ReDoc render it, client generators compile it, a gateway validates against it, and CI diffs it to catch breaking changes.

  1. 1 · The generator walks the URLconf — Every routed view becomes a path and a set of operations. Nothing here is written by hand, so nothing here can drift.
  2. 2 · Serializers become schemas — Field types, required-ness, choices, and read-only flags all transfer. A vague serializer produces a vague schema.
  3. 3 · You annotate what it cannot infer — Custom actions, error shapes, and dynamic serializer selection are Python control flow — invisible to a static pass until declared.
  4. 4 · One document, four consumers — Swagger UI and ReDoc render it, client generators compile it, a gateway validates against it, and CI diffs it to catch breaking changes.

What the generator reads, and what it cannot see

What the generator reads, and what it cannot see
Part of the APIInferred fromAccurate by default?
Paths and methodsthe URLconf and the ViewSet's actionsyes
Request body`serializer_class`yes
Success response body`serializer_class`yes, for generic views
Query parametersthe filter backends and pagination classmostly — custom backends need annotating
Response of a custom `@action`nothing — it is plain Python**no** — annotate it
Error responses (400/403/409)nothing — exceptions are not static**no** — declare them
Auth requirementsthe authentication classesyes, once the security scheme is configured

Together

python
@extend_schema(
    request=CancelSerializer,
    responses={200: OrderSerializer, 409: ErrorSerializer},
)
@action(detail=True, methods=["post"])
def cancel(self, request, pk=None):
    ...

Remember: OpenAPI is a machine-readable description of the API, and in DRF it is generated from your URLconf and serializers rather than written. Generation makes it a correctness tool: it describes what you shipped, and a gap in the document is a gap in the code. But it can only read what is declarative — custom `@action` responses, dynamic serializer selection, and every error shape are invisible until annotated with `@extend_schema`. Commit the generated file and diff it in CI, so a breaking change becomes a line someone has to approve.

See also: swagger ui redoc auth and examples · drf spectacular and drf yasg · versioning backward compatibility and deprecation · custom pagination and page size limits

Advertisement

Rendering and completing it

Swagger UI and ReDoc, plus the security scheme, examples, and per-version documents you supply yourself.

Swagger UI, ReDoc, documenting auth, examples, and versioning

standardintermediate

One OpenAPI document, two common renderers. **Swagger UI** is interactive — it shows a "Try it out" button that sends a real request from the browser, which makes it excellent for exploring and risky to point at production. **ReDoc** is read-only, three-column, and much easier to read for a large API. Both are served from the same schema, so serving both costs one extra URL. Two things then need deliberate work, because a generator cannot infer either: the **security scheme** (declaring that endpoints take `Authorization: Bearer <jwt>`, so the docs let a reader authenticate and the generated clients know how), and **examples** — realistic request and response bodies, which are what turn a schema into something someone can actually copy and use. Each API version gets its own document.

Think of it as

A schema tells a reader what is *possible*; an example tells them what is *typical*, and typical is what people copy. A field described as `string` with no example leaves every reader guessing whether it wants an ISO date, a slug, or a UUID — the schema is correct and the reader is still stuck. That is why examples repay their cost faster than almost anything else in an API document. The auth declaration is the other piece a generator cannot derive, because the mapping from "this view uses `JWTAuthentication`" to "send `Authorization: Bearer <token>`" is a convention, not something readable from the class. Declare it once as a security scheme, and both the docs and every generated client learn it. On versioning: a document describes exactly one version of an API, so v1 and v2 get separate documents at separate URLs. Merging them produces a page where every endpoint carries a footnote about which version it applies to, which is unreadable and, worse, unusable by a client generator that has no way to filter.

python
@extend_schema(
    examples=[OpenApiExample("A weekly export", value={"since": "2026-09-01", "format": "csv"})],
)

What we're doing: Serve both renderers, declare JWT so the Authorize button works, and attach an example a reader can copy.

config/settings.py + config/urls.py + exports/views.pypython
SPECTACULAR_SETTINGS = {
    "TITLE": "Orders API",
    "VERSION": "1.0.0",
    "SERVE_INCLUDE_SCHEMA": False,
    "SECURITY": [{"jwtAuth": []}],
    "APPEND_COMPONENTS": {
        "securitySchemes": {
            "jwtAuth": {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"},
        },
    },
}

urlpatterns = [
    path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
    path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema")),
    path("api/redoc/", SpectacularRedocView.as_view(url_name="schema")),
]


class ExportViewSet(viewsets.GenericViewSet):
    @extend_schema(
        summary="Request a CSV export",
        request=ExportRequestSerializer,
        responses={202: ExportJobSerializer},
        examples=[
            OpenApiExample(
                "Last month, as CSV",
                value={"since": "2026-08-01", "until": "2026-08-31", "format": "csv"},
                request_only=True,
            ),
            OpenApiExample(
                "Accepted",
                value={"job_id": "7c1f", "status": "queued",
                       "poll_url": "/api/exports/7c1f/"},
                response_only=True,
            ),
        ],
    )
    def create(self, request):
        ...
5–10
The two halves of documenting auth: `SECURITY` says every operation needs it, and the component says what "it" is. Without the component the Authorize dialog has nothing to offer.
14–16
One schema URL feeding two renderers. Both always describe the same API, because there is only one document.
24
`responses={202: ...}` — the status code matters. An export that is queued rather than produced is a 202, and a client generated from a 200 would look for a body that never arrives.
25–35
`request_only` and `response_only` keep the two examples on the right side of the operation. Real dates and a real-looking job id are the difference between an example someone copies and one they have to decode.

Why this works: The generator handles the shape; these three additions handle everything it cannot see — how to authenticate, what a realistic call looks like, and which status the success case actually returns.

One document, two renderers, and the two things you must declare yourself

Swagger UI · /api/docs/

"Try it out" hits the live API

the fastest way to explore an endpoint

An Authorize dialog

works only if a security scheme is declared

Gate it in production

it is a working API client on a URL

ReDoc · /api/redoc/

Three columns, persistent nav

stays readable at a hundred endpoints

Shows examples side by side

which is what makes examples worth writing

Sends nothing

safe to publish

What you must add

The security scheme

bearerFormat: JWT — the header convention is not in the class

Realistic examples

"2026-09-04" teaches the format; "string" teaches nothing

One document per version

v1 and v2 are separate URLs, never merged

  • openapi.yaml
  • Swagger UI · /api/docs/ — interactive — it sends real requests
    • "Try it out" hits the live API — the fastest way to explore an endpoint
    • An Authorize dialog — works only if a security scheme is declared
    • Gate it in production — it is a working API client on a URL
  • ReDoc · /api/redoc/ — read-only reference
    • Three columns, persistent nav — stays readable at a hundred endpoints
    • Shows examples side by side — which is what makes examples worth writing
    • Sends nothing — safe to publish
  • What you must add — a generator cannot infer either of these
    • The security scheme — bearerFormat: JWT — the header convention is not in the class
    • Realistic examples — "2026-09-04" teaches the format; "string" teaches nothing
    • One document per version — v1 and v2 are separate URLs, never merged

Swagger UI or ReDoc?

Swagger UI or ReDoc?
PropertySwagger UIReDoc
Sends real requestsyes — "Try it out"no
Best forexploring and debugging an endpointreading a large API end to end
Layoutone accordion columnthree columns, with a persistent nav
Auth entryan "Authorize" dialog that then signs requestsdisplays the requirement only
Production riska working API client on a public URLread-only, so much lower
Typical usestaging, or behind a loginpublic reference documentation

Together

python
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema")),
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema")),

Remember: Swagger UI is interactive and therefore a working API client — gate it in production; ReDoc is read-only and reads better at scale. Both render the same generated document, so serving both costs one URL. Declare the security scheme yourself: the header convention is not derivable from an authentication class, and without it the Authorize button and every generated client are blind. Write realistic examples, because typical is what people copy. And give each API version its own document rather than merging them.

See also: openapi and schema generation · drf spectacular and drf yasg · session token and jwt authentication

Advertisement

The tooling

drf-spectacular for new work, drf-yasg where you inherit it, and what the version gap costs.

drf-spectacular, and drf-yasg in legacy codebases

standardintermediate

Three tools generate schemas for DRF. **DRF's built-in** `AutoSchema` produces OpenAPI 3 and is deliberately minimal; DRF's own documentation points at third-party packages for anything real. **drf-spectacular** is the current default choice: OpenAPI 3.x, `@extend_schema` for everything the generator cannot infer, and a `--fail-on-warn` mode that turns "the generator had to guess here" into a CI failure. **drf-yasg** is the previous generation and produces OpenAPI **2.0** (Swagger) only — it is still widely deployed, so you will meet it, but it is not what you start a new project with. Its decorator is `@swagger_auto_schema`, which is the drf-spectacular `@extend_schema` equivalent.

Think of it as

The version number is the whole story. OpenAPI 3 added the things people actually hit — `oneOf`/`anyOf` for a field that can be more than one shape, reusable request-body components, multiple named examples per operation, and a proper `servers` list. drf-yasg cannot express any of them, because 2.0 has no syntax for them, and that is a limit of the specification rather than of the library — so "migrate drf-yasg to OpenAPI 3" is not a setting. When you land in a codebase using it, the useful judgement is whether the schema is load-bearing: if it only renders a docs page nobody generates clients from, leaving it alone is reasonable. If clients are generated from it, or a gateway validates against it, the 2.0 ceiling will keep costing you, and the migration is mostly mechanical — swap the decorator, re-point the URLs, then work through the warnings. The `--fail-on-warn` flag is the part worth adopting on day one either way: every warning is a place the generator guessed, which is a place the document is quietly wrong, and letting CI enforce zero warnings is what keeps a generated schema trustworthy rather than merely present.

bash
pip install drf-spectacular
# settings: DEFAULT_SCHEMA_CLASS = "drf_spectacular.openapi.AutoSchema"
python manage.py spectacular --file openapi.yaml --fail-on-warn

What we're doing: Make an incomplete schema fail the build, so the document stays true rather than merely present.

.github/workflows/ci.ymlyaml
- name: Generate the OpenAPI schema
  run: |
    python manage.py spectacular \
      --file openapi.yaml \
      --fail-on-warn

- name: Fail if the committed schema is out of date
  run: git diff --exit-code openapi.yaml
5
`--fail-on-warn` treats every "could not infer the serializer for …" as an error. Each warning is a place the document is guessing, which is a place a generated client will be wrong.
7–8
Diffing the committed file makes a schema change visible in the pull request. A breaking change is then something a reviewer approves rather than something a client discovers.

Why this works: These two steps convert the schema from documentation into a checked artifact: it cannot silently become incomplete, and it cannot silently change.

The same endpoint, annotated for each package

drf-spectacular — OpenAPI 3

  • +Responses keyed by status code, each with its own serializer.
  • +Several named examples per operation, split request/response.
  • +oneOf for a field that is genuinely more than one shape.
  • +--fail-on-warn turns a guessed schema into a failing build.
  • +The default choice for anything new.

drf-yasg — OpenAPI 2.0

  • Same idea, same serializers, an older specification underneath.
  • One example per response — 2.0 has no syntax for more.
  • No oneOf, so a polymorphic field flattens to something less exact.
  • Still common in production; expect to meet it.
  • Migrating is mechanical, but it is a decorator sweep, not a setting.
  • drf-spectacular — OpenAPI 3
    • Responses keyed by status code, each with its own serializer.
    • Several named examples per operation, split request/response.
    • oneOf for a field that is genuinely more than one shape.
    • --fail-on-warn turns a guessed schema into a failing build.
    • The default choice for anything new.
  • drf-yasg — OpenAPI 2.0
    • Same idea, same serializers, an older specification underneath.
    • One example per response — 2.0 has no syntax for more.
    • No oneOf, so a polymorphic field flattens to something less exact.
    • Still common in production; expect to meet it.
    • Migrating is mechanical, but it is a decorator sweep, not a setting.

The three generators

The three generators
PropertyDRF built-in`drf-spectacular``drf-yasg`
OpenAPI version3.03.0 / 3.1**2.0 only**
Annotation decoratorsubclass `AutoSchema``@extend_schema``@swagger_auto_schema`
Multiple examples per operationnoyesno (2.0 limit)
`oneOf` / polymorphic fieldsnoyesno (2.0 limit)
CI gate on incomplete schemano`--fail-on-warn`no
Use it whena schema is barely neededany new projectyou inherited it

Together

bash
python manage.py spectacular --file openapi.yaml --fail-on-warn

Remember: drf-spectacular for anything new — OpenAPI 3, `@extend_schema`, and `--fail-on-warn` so a guessed schema fails CI. drf-yasg is the previous generation and is limited to OpenAPI 2.0 by the specification, not by configuration, so it cannot express `oneOf`, reusable request bodies, or multiple examples. You will meet it in existing codebases; migrating is a mechanical decorator sweep, worth doing when the schema is load-bearing and easy to defer when it only renders a page.

See also: openapi and schema generation · swagger ui redoc auth and examples

Advertisement