Django DRF #5: OpenAPI Docs — drf-spectacular
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-yasg | drf-spectacular | |
|---|---|---|
| OpenAPI version | 2.0 (Swagger) | 3.x |
| Activity | Maintenance mode | Active |
| Type-hint inference | Decent | Strong |
| 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 #
uv add drf-spectacularINSTALLED_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 #
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:
| URL | What |
|---|---|
/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.
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.
| Source | Converted into the schema |
|---|---|
serializer_class | request / response body |
queryset.model | response object type |
filter_backends, filterset_class | query parameters (filters) |
ordering_fields, search_fields | ?ordering=, ?search= parameters |
pagination_class | response envelope (count/next/previous) |
permission_classes | whether auth is required, 401 response |
{pk} in URL | path 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.
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
#
@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.
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,
)@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 #
REST_FRAMEWORK = {
...,
"EXCEPTION_HANDLER": "blog.exceptions.custom_exception_handler",
}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 responseWhen 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:
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.
@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 #
REST_FRAMEWORK = {
...,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
"DEFAULT_VERSION": "v1",
"ALLOWED_VERSIONS": ["v1", "v2"],
}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.
# 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 ./clientWhen 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.
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 intentionalDedicated 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 + spectacular | FastAPI | |
|---|---|---|
| Auto docs | Install/config spectacular | Built-in (/docs) |
| Auto inference | Based on ViewSet/Serializer | Based on function signature/Pydantic |
| Extra spec | @extend_schema decorator | Function signature + response_model |
| Explicitness | Needs more decorators | Signature itself is the spec |
| Result quality | Very good | Very good |
| Learning curve | Learn the spectacular pattern | Almost 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:
@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 --validateas 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 methodsinline_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,@actionserializer, 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.