Django DRF #5: OpenAPI Docs — drf-spectacular

9 min read

The API built across #1#4 has grown into something substantial. The next step is aligning the contract with the frontend. The answer is automatic OpenAPI docs.

Why automatic docs are valuable #

When working with frontend and mobile teams, the recurring question “what’s the response format of this endpoint?” disappears with auto docs.

  • Contract — Swagger UI is the truth. Slack/Notion docs are not.
  • Auto client SDK generation — generate TypeScript / Swift / Kotlin SDKs from the OpenAPI spec
  • Tests — schema diff catches breaking changes
  • External exposure — a good format to expose to outside developers
  • Onboarding — new team members see the whole API at a glance

Code and docs stay in sync — the function signature is the doc.

drf-spectacular vs drf-yasg #

The two branches in the DRF world.

drf-yasgdrf-spectacular
OpenAPI version2.0 (Swagger)3.x
ActivityMaintenance modeActive
Type-hint inferenceDecentStrong
Recommended (today)For new projects

With OpenAPI 3.x now the de facto standard, drf-spectacular is the first choice for new projects. This post covers spectacular.

Install #

Package
uv add drf-spectacular
settings.py
INSTALLED_APPS += ["drf_spectacular"]

REST_FRAMEWORK = {
    ...,
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}

SPECTACULAR_SETTINGS = {
    "TITLE": "Blog API",
    "DESCRIPTION": "Example blog API for the Django DRF series",
    "VERSION": "1.0.0",
    "SERVE_INCLUDE_SCHEMA": False,         # only the dedicated schema URL
    "COMPONENT_SPLIT_REQUEST": True,       # split request/response components
    "SCHEMA_PATH_PREFIX": "/api/",
    "SERVERS": [
        {"url": "https://api.example.com", "description": "Production"},
        {"url": "http://localhost:8000", "description": "Local"},
    ],
    "TAGS": [
        {"name": "posts", "description": "Post CRUD"},
        {"name": "comments", "description": "Comments"},
        {"name": "auth", "description": "Authentication"},
    ],
}

URL routes #

mysite/urls.py
from drf_spectacular.views import (
    SpectacularAPIView,
    SpectacularSwaggerView,
    SpectacularRedocView,
)

urlpatterns = [
    ...,
    # Raw OpenAPI schema (JSON / YAML)
    path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
    # Human UIs
    path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"),
    path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
]

That’s it. Verify in a browser:

URLWhat
/api/schema/OpenAPI 3.x JSON (or ?format=openapi-json/-yaml)
/api/docs/Swagger UI — Try it out enabled
/api/redoc/ReDoc style (clean, read-focused docs)

Extract via CLI #

Useful when you want to output a schema file in a CI step.

Generate schema file
uv run python manage.py spectacular --file schema.yml --validate

--validate checks that it doesn’t violate the OpenAPI 3.x spec. Passing this in CI is the first gate.

Auto inference — what you get for free #

drf-spectacular builds a rich schema just from ViewSet/Serializer alone.

SourceConverted into the schema
serializer_classrequest / response body
queryset.modelresponse object type
filter_backends, filterset_classquery parameters (filters)
ordering_fields, search_fields?ordering=, ?search= parameters
pagination_classresponse envelope (count/next/previous)
permission_classeswhether auth is required, 401 response
{pk} in URLpath parameter
@action(detail=True/False)extra routes

If you leave the code from #1~#3 as-is, Swagger UI already includes:

  • /api/posts/ GET (list, pagination, filter/sort/search params)
  • /api/posts/ POST (PostSerializer input, 201 response)
  • /api/posts/{id}/ GET/PUT/PATCH/DELETE
  • /api/posts/{id}/publish/ POST
  • 401 / 403 responses shown (perm-class based)

All of that is in there.

@extend_schema — precise spec #

The parts that auto inference can’t catch — response examples, multiple responses, extra parameters, error schemas — go in via decorators.

blog/views.py
from drf_spectacular.utils import (
    extend_schema,
    extend_schema_view,
    OpenApiParameter,
    OpenApiResponse,
    OpenApiExample,
    inline_serializer,
)
from rest_framework import serializers, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Post
from .serializers import PostSerializer


@extend_schema_view(
    list=extend_schema(
        summary="Post list",
        description="Returns published posts + my posts paginated.",
        tags=["posts"],
    ),
    retrieve=extend_schema(summary="Post detail", tags=["posts"]),
    create=extend_schema(summary="Create post", tags=["posts"]),
    update=extend_schema(summary="Full update post", tags=["posts"]),
    partial_update=extend_schema(summary="Partial update post", tags=["posts"]),
    destroy=extend_schema(summary="Delete post", tags=["posts"]),
)
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    @extend_schema(
        summary="Publish post",
        description="Switches a draft post to published. Author only.",
        request=None,
        responses={
            200: inline_serializer(
                name="PublishResponse",
                fields={"published": serializers.BooleanField()},
            ),
            403: OpenApiResponse(description="Not the author"),
            404: OpenApiResponse(description="Post not found"),
        },
        tags=["posts"],
    )
    @action(detail=True, methods=["post"])
    def publish(self, request, pk=None):
        post = self.get_object()
        post.published = True
        post.save()
        return Response({"published": True})

Where @extend_schema_view fits #

ViewSet’s automatic methods (list, retrieve, create, …) aren’t defined by us, so we can’t attach decorators to them. @extend_schema_view attaches decorators to those methods at the class level all at once.

request=None #

The publish action takes no request body. Specify request=None — without it, spectacular infers from serializer_class (PostSerializer) and produces an incorrect schema.

inline_serializer #

When the response shape is small, use inline instead of creating a separate Serializer class. Larger structures belong in dedicated Serializer classes for reuse.

Response examples — OpenApiExample #

Add response examples
@extend_schema(
    responses={
        200: PostSerializer,
    },
    examples=[
        OpenApiExample(
            "Published post",
            value={
                "id": 1,
                "title": "Getting started with Django",
                "body": "...",
                "published": True,
                "created_at": "2026-05-01T10:00:00Z",
            },
            response_only=True,
        ),
        OpenApiExample(
            "Draft",
            value={
                "id": 2,
                "title": "In progress",
                "body": "...",
                "published": False,
                "created_at": "2026-05-02T11:00:00Z",
            },
            response_only=True,
        ),
    ],
)
def retrieve(self, request, *args, **kwargs):
    return super().retrieve(request, *args, **kwargs)

In Swagger UI, developers can toggle through examples to quickly get a feel for the response.

Error schema — consistent format #

DRF’s default error responses have inconsistent shapes (validation errors are a dict, other exceptions are {"detail": "..."}). Better to define a consistent error envelope and declare it on every 4xx/5xx response.

blog/serializers.py — errors
from rest_framework import serializers


class ErrorDetailSerializer(serializers.Serializer):
    detail = serializers.CharField()
    code = serializers.CharField(required=False)
    field_errors = serializers.DictField(
        child=serializers.ListField(child=serializers.CharField()),
        required=False,
    )
In ViewSet
@extend_schema(
    responses={
        201: PostSerializer,
        400: ErrorDetailSerializer,
        401: ErrorDetailSerializer,
        403: ErrorDetailSerializer,
    },
)
def create(self, request, *args, **kwargs):
    return super().create(request, *args, **kwargs)

Pair with global error handler #

settings.py
REST_FRAMEWORK = {
    ...,
    "EXCEPTION_HANDLER": "blog.exceptions.custom_exception_handler",
}
blog/exceptions.py
from rest_framework.views import exception_handler


def custom_exception_handler(exc, context):
    response = exception_handler(exc, context)
    if response is None:
        return None

    data = {"detail": response.data.get("detail", str(exc))}
    if isinstance(response.data, dict) and "detail" not in response.data:
        data = {"detail": "Validation failed", "field_errors": response.data}
    response.data = data
    return response

When the response format is always consistent, client code becomes simpler and the schema stays honest.

Auth header spec #

To make Swagger UI’s “Authorize” button recognize the JWT auth from #2 — it’s auto-detected, but if you want it explicit:

settings.py
SPECTACULAR_SETTINGS = {
    ...,
    "SECURITY": [{"BearerAuth": []}],
    "AUTHENTICATION_WHITELIST": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ],
    "APPEND_COMPONENTS": {
        "securitySchemes": {
            "BearerAuth": {
                "type": "http",
                "scheme": "bearer",
                "bearerFormat": "JWT",
            }
        }
    },
}

In Swagger UI, get a token via /api/auth/token/, paste Bearer <access> into the “Authorize” dialog, and every subsequent request carries the header automatically — authenticated calls work immediately with “Try it out.”

Query parameter spec — OpenApiParameter #

Specify query parameters in @action or custom views.

Extra query parameters
@extend_schema(
    parameters=[
        OpenApiParameter(
            name="since",
            type=str,
            location=OpenApiParameter.QUERY,
            description="ISO 8601 datetime — posts after this",
            required=False,
            examples=[OpenApiExample("Last 7 days", value="2026-05-01T00:00:00Z")],
        ),
    ],
    responses={200: PostSerializer(many=True)},
)
@action(detail=False, methods=["get"])
def recent(self, request):
    ...

Spectacular auto-detects parameters from DjangoFilterBackend, OrderingFilter, and SearchFilter, so no need to spell them out. Only fill in what auto inference can’t catch.

Versioning — API versions #

settings.py
REST_FRAMEWORK = {
    ...,
    "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
    "DEFAULT_VERSION": "v1",
    "ALLOWED_VERSIONS": ["v1", "v2"],
}
urls.py
urlpatterns = [
    path("api/<version>/", include("blog.urls")),
]

drf-spectacular builds different schemas per version. Clients can branch via headers like Accept-Version: v2.

Auto-generating client SDKs #

A big payoff of OpenAPI — auto-generating TypeScript / Swift / Kotlin SDKs. Frontend doesn’t have to write API call code by hand.

openapi-generator or openapi-typescript
# TypeScript types only
npx openapi-typescript http://localhost:8000/api/schema/ -o api.d.ts

# Full SDK (axios client + types)
npx @openapitools/openapi-generator-cli generate \
  -i http://localhost:8000/api/schema/ \
  -g typescript-axios \
  -o ./client

When the backend updates the spec, the frontend regenerates the SDK — a type-safe, contract-first workflow.

Schema regression test — catching breaking changes #

This is covered in depth in #6, but the pattern is straightforward: inspect the schema diff in CI to block unintended breaking changes.

Extract then compare
uv run python manage.py spectacular --file schema.yml --validate
git diff --exit-code schema.yml   # if it changed, ask the PR whether it was intentional

Dedicated tools like oasdiff give finer diffs and breaking-change classification.

Compared to FastAPI’s automatic OpenAPI #

As seen in FastAPI #1, FastAPI ships auto docs built-in. DRF needs an extra library (spectacular) plus more decorators.

Django + DRF + spectacularFastAPI
Auto docsInstall/config spectacularBuilt-in (/docs)
Auto inferenceBased on ViewSet/SerializerBased on function signature/Pydantic
Extra spec@extend_schema decoratorFunction signature + response_model
ExplicitnessNeeds more decoratorsSignature itself is the spec
Result qualityVery goodVery good
Learning curveLearn the spectacular patternAlmost none

If you already have a large Django project, the cost of adding spectacular is small and the benefit is large. Both approaches ultimately converge on OpenAPI 3.x, so client SDK generation and external tooling work the same way.

Common pitfalls #

1) Auto-inferred schema looks awkward #

A ModelSerializer built with fields = "__all__" exposes fields like password_hash. Explicit fields are safer (same recommendation as in #1).

2) Multiple response formats #

When the same action returns different responses depending on context, like PostSerializer(many=True) vs PostSerializer(), be explicit with responses={200: PostSerializer(many=True)}. Auto inference isn’t always right.

3) serializer_class on @action #

To use different inputs/outputs in @action, be explicit:

@action serialization
@extend_schema(request=PublishRequestSerializer, responses=PublishResponseSerializer)
@action(detail=True, methods=["post"], serializer_class=PublishRequestSerializer)
def publish(self, request, pk=None): ...

4) Stale schema #

You changed the code but the schema is from the old format. manage.py spectacular --file ... --validate catches import-order or generic errors. Add it to CI — broken schema generation should fail the build.

5) EXCEPTION_HANDLER and schema mismatch #

You changed the response format with a custom exception handler but the schema still has the old format. Be precise in responses= about the response format your exception handler produces.

Recap #

What this post nailed down:

  • Where automatic docs are valuable — contract, SDK, tests, external exposure, onboarding
  • drf-spectacular vs drf-yasg — in the OpenAPI 3.x era, spectacular
  • Install + SPECTACULAR_SETTINGS + URL routes — /schema/, /docs/, /redoc/
  • manage.py spectacular --validate as a CI gate
  • What you get from auto inference — Serializer / filters / pagination / permissions / @action
  • @extend_schema — summary / description / request / responses / tags
  • @extend_schema_view — decorate ViewSet automatic methods
  • inline_serializer, OpenApiExample, OpenApiResponse, OpenApiParameter
  • Consistent error schema + global EXCEPTION_HANDLER
  • JWT auth spec, Swagger UI Authorize behavior
  • API versioning
  • Auto-generating client SDKs (openapi-typescript, openapi-generator)
  • Detect breaking changes via schema regression tests
  • Compared to FastAPI’s built-in auto docs
  • Pitfalls — __all__, multiple responses, @action serializer, stale schema, exception handler

Not as natural as FastAPI #1’s built-in /docs, but explicitly building a schema with spectacular ultimately produces more accurate docs — multiple responses, error schemas, and examples that auto inference can’t capture are all accounted for.

The next post (#6 Testing and deployment) is the last in the series — APITestCase / APIClient / pytest-django for automated tests, then Docker / gunicorn / nginx for production deployment, all in one place.

X