How to Document a Django Backend (DRF Walkthrough)
You built a Django REST Framework API. It works. The internal team ships against it. Now an external partner wants integration docs, or a new hire asks why /api/v2/invoices/ returns a 403 when they pass a valid token, and the only answer lives in the head of whoever wrote the viewset. Auto-generated reference is the floor, not the ceiling. But for a Django backend, the floor is the first thing to get right, because the schema you extract from your code is what every downstream doc tool reads.
This walkthrough documents a Django REST Framework backend end to end with drf-spectacular, the OpenAPI 3 schema generator that has quietly replaced the old coreapi and drf-yasg defaults. You will ship a browsable Swagger UI, a ReDoc page, a versioned schema.yml file in git, and a public-facing docs site built from that schema. The same pattern in this post applies whether you document a Django project with ten endpoints or a hundred.
What you will build
Documentation for a hypothetical billing API called Ledgerly with three resource groups: customers (create, retrieve, list), invoices (CRUD plus a send action), and payments (record a payment, list payments for an invoice). Auth is JWT via djangorestframework-simplejwt. Substitute your own app. The commands, file paths, and code are real.
Prerequisites
Before starting, confirm you have:
- Python 3.12 or newer (
python --version) - Django 5.x and Django REST Framework installed in a working project
- A Django project that runs with
python manage.py runserver piporuvfor installing packages- A GitHub repo for the project if you want docs to rebuild on push
- Optional: a Docsio account for the hosted path in step 5
If you are starting a new project from scratch:
uv venv && source .venv/bin/activate
uv pip install "django>=5.0" djangorestframework djangorestframework-simplejwt
django-admin startproject ledgerly
cd ledgerly && python manage.py startapp billing
Add rest_framework and billing to INSTALLED_APPS and you have the baseline app this walkthrough assumes.
Step 1: Install drf-spectacular and wire up the schema
drf-spectacular reads your viewsets, serializers, and models and emits a valid OpenAPI 3.1 schema. It is the only OpenAPI generator for DRF that is actively maintained and the one the DRF docs now link to first.
Install it:
pip install drf-spectacular
Or with uv:
uv pip install drf-spectacular
Add it to INSTALLED_APPS and register it as the default schema class in settings.py:
# ledgerly/settings.py
INSTALLED_APPS = [
# ...
"rest_framework",
"drf_spectacular",
"billing",
]
REST_FRAMEWORK = {
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
),
"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.IsAuthenticated",
),
}
SPECTACULAR_SETTINGS = {
"TITLE": "Ledgerly API",
"DESCRIPTION": "Billing, invoices, and payments for SaaS teams.",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"CONTACT": {"name": "Ledgerly Support", "url": "https://ledgerly.example/support"},
"LICENSE": {"name": "MIT"},
"TAGS": [
{"name": "customers", "description": "Create and manage customer records."},
{"name": "invoices", "description": "Issue, send, and track invoices."},
{"name": "payments", "description": "Record payments against invoices."},
],
"COMPONENT_SPLIT_REQUEST": True,
"SWAGGER_UI_SETTINGS": {
"persistAuthorization": True,
"displayOperationId": False,
},
}
Expose the schema and two UI viewers in urls.py:
# ledgerly/urls.py
from django.urls import path, include
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularSwaggerView,
SpectacularRedocView,
)
urlpatterns = [
path("api/", include("billing.urls")),
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger"),
path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"),
]
Start the server and hit three URLs:
http://localhost:8000/api/schema/returns OpenAPI YAMLhttp://localhost:8000/api/docs/renders Swagger UIhttp://localhost:8000/api/redoc/renders ReDoc
If those three load, the reference layer is live. The rest of this walkthrough is about making the schema say something useful.
Step 2: Document models via docstrings and field help_text
The schema pulls field-level metadata straight from help_text, verbose_name, and model docstrings. Most Django codebases leave these blank. Filling them in is the single biggest-return change you can make for Django documentation, because the output surfaces in the admin, the OpenAPI schema, and any auto-generated client SDK.
Open your models and treat every field description as a one-liner that a new developer could read cold:
# billing/models.py
from django.db import models
class Customer(models.Model):
"""A billable customer. One customer can own many invoices."""
email = models.EmailField(
unique=True,
help_text="Primary billing email. Must be unique across all customers.",
)
display_name = models.CharField(
max_length=200,
help_text="Name shown on invoices and receipts.",
)
created_at = models.DateTimeField(
auto_now_add=True,
help_text="UTC timestamp when the customer was created.",
)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.display_name} <{self.email}>"
class Invoice(models.Model):
"""An invoice issued to a customer. Transitions draft -> open -> paid."""
STATUS_CHOICES = [
("draft", "Draft"),
("open", "Open"),
("paid", "Paid"),
("void", "Void"),
]
customer = models.ForeignKey(
Customer,
on_delete=models.PROTECT,
related_name="invoices",
help_text="The customer this invoice is issued to.",
)
amount_cents = models.PositiveIntegerField(
help_text="Total amount in cents. Stored in the invoice currency.",
)
currency = models.CharField(
max_length=3,
default="USD",
help_text="ISO 4217 currency code. Defaults to USD.",
)
status = models.CharField(
max_length=10,
choices=STATUS_CHOICES,
default="draft",
help_text="Lifecycle state. Invoices can only be sent once in `open`.",
)
due_at = models.DateTimeField(help_text="Payment due date in UTC.")
Two things happen automatically when drf-spectacular reads this. The help_text strings become description fields on every property in the OpenAPI schema. The STATUS_CHOICES become an enum on the status property, not a free-form string. Anyone reading your docs now sees exactly which values are legal.
One Django-specific gotcha: PositiveIntegerField and CharField(choices=...) both map cleanly, but custom model fields need explicit type hints. If you have a JSONField or a custom field type, add extend_schema_field on the serializer (covered in step 3) so the schema does not fall back to "type": "object" with no detail.
Step 3: Document serializers and viewsets
The schema reads serializers for request and response shapes, and viewsets for endpoints, tags, and operation descriptions. Get these two files right and the Swagger UI becomes legible.
Start with the serializers. Field descriptions carry over from the model, but anything that is computed or validator-driven needs an explicit hint:
# billing/serializers.py
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from .models import Customer, Invoice
class CustomerSerializer(serializers.ModelSerializer):
invoice_count = serializers.SerializerMethodField()
class Meta:
model = Customer
fields = ["id", "email", "display_name", "created_at", "invoice_count"]
read_only_fields = ["id", "created_at", "invoice_count"]
@extend_schema_field(serializers.IntegerField(help_text="Number of invoices ever issued to this customer."))
def get_invoice_count(self, obj) -> int:
return obj.invoices.count()
class InvoiceSerializer(serializers.ModelSerializer):
class Meta:
model = Invoice
fields = [
"id", "customer", "amount_cents", "currency",
"status", "due_at",
]
read_only_fields = ["id", "status"]
def validate_amount_cents(self, value):
if value <= 0:
raise serializers.ValidationError("Amount must be greater than zero.")
return value
Now the viewset. Viewsets are where you control tagging, the per-endpoint description, response examples, and custom actions. @extend_schema is the decorator you will use the most:
# billing/views.py
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from drf_spectacular.utils import (
extend_schema,
extend_schema_view,
OpenApiExample,
OpenApiResponse,
)
from .models import Customer, Invoice
from .serializers import CustomerSerializer, InvoiceSerializer
@extend_schema_view(
list=extend_schema(
summary="List customers",
description="Paginated list of customers ordered by newest first.",
tags=["customers"],
),
create=extend_schema(
summary="Create a customer",
tags=["customers"],
examples=[
OpenApiExample(
"Typical customer",
value={"email": "jane@acme.test", "display_name": "Jane at Acme"},
request_only=True,
),
],
),
)
class CustomerViewSet(viewsets.ModelViewSet):
queryset = Customer.objects.all()
serializer_class = CustomerSerializer
class InvoiceViewSet(viewsets.ModelViewSet):
queryset = Invoice.objects.select_related("customer")
serializer_class = InvoiceSerializer
@extend_schema(
summary="Send an invoice to the customer",
description=(
"Transitions the invoice from `draft` to `open` and emails the "
"customer a payment link. Idempotent: calling twice returns 200 "
"without re-sending."
),
tags=["invoices"],
responses={
200: OpenApiResponse(description="Invoice already sent, no action taken."),
201: InvoiceSerializer,
409: OpenApiResponse(description="Invoice is void and cannot be sent."),
},
)
@action(detail=True, methods=["post"])
def send(self, request, pk=None):
invoice = self.get_object()
# ... real send logic
return Response(InvoiceSerializer(invoice).data, status=status.HTTP_201_CREATED)
Three things to notice. The tags argument groups endpoints by resource in Swagger, which matters once you have more than ten endpoints. OpenApiExample puts a real payload in the request body preview, which is what integrators actually copy. OpenApiResponse documents every status code the endpoint can return, not just the 200, and that is usually where the Django documentation gap hurts worst.
Wire the router up in billing/urls.py:
# billing/urls.py
from rest_framework.routers import DefaultRouter
from .views import CustomerViewSet, InvoiceViewSet
router = DefaultRouter()
router.register("customers", CustomerViewSet, basename="customer")
router.register("invoices", InvoiceViewSet, basename="invoice")
urlpatterns = router.urls
Reload Swagger UI and every endpoint now has a real description, tagged group, example request, and documented error responses.
Step 4: Document the authentication flow
Auth is where most Django REST Framework documentation falls apart. The schema might correctly say "bearer token required," but it never explains where the token comes from, how long it lives, or what happens on expiry. Fix that in two places: the schema, and a handwritten concept page.
For Ledgerly we are using JWT via simplejwt. Wire the token endpoints and tell drf-spectacular about them:
# ledgerly/urls.py
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from drf_spectacular.utils import extend_schema
# Tag the built-in JWT views so they appear in the auth group
auth_obtain = extend_schema(
summary="Obtain an access and refresh token pair",
description="POST your email and password to receive a 15-minute access token and a 7-day refresh token.",
tags=["auth"],
)(TokenObtainPairView.as_view())
auth_refresh = extend_schema(
summary="Refresh an access token",
description="POST a valid refresh token to receive a new access token. The refresh token is rotated.",
tags=["auth"],
)(TokenRefreshView.as_view())
urlpatterns += [
path("api/auth/token/", auth_obtain, name="token_obtain"),
path("api/auth/token/refresh/", auth_refresh, name="token_refresh"),
]
Then set lifetime explicitly so the docs can quote real numbers:
# ledgerly/settings.py
from datetime import timedelta
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
"REFRESH_TOKEN_LIFETIME": timedelta(days=7),
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
"AUTH_HEADER_TYPES": ("Bearer",),
}
Finally, give Swagger UI a security scheme so the "Authorize" button actually works:
# ledgerly/settings.py (add to SPECTACULAR_SETTINGS)
SPECTACULAR_SETTINGS = {
# ... existing settings
"SECURITY": [{"BearerAuth": []}],
"APPEND_COMPONENTS": {
"securitySchemes": {
"BearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
}
}
},
}
That closes the loop on reference. The handwritten side is one page, roughly 300 to 500 words, that walks a new developer through: signing up, calling /api/auth/token/ to get a pair, storing the refresh token, handling a 401, and rotating when the refresh token hits its 7-day cap. Token, session, and JWT auth all map to the same concept page pattern. Pick whichever your API uses and write one page per flow.
Step 5: Publish the docs
You now have a Django backend with a complete OpenAPI schema, two in-tree UI viewers, and a documented auth flow. Two paths to ship a public-facing site.
Path A: export the schema to a file and host anywhere. Run this in CI on every merge to main:
python manage.py spectacular --format openapi-yaml --file schema.yml
Commit schema.yml to the repo. Any docs tool that reads OpenAPI, including ReDoc, Swagger UI standalone, Stoplight, or Docsio, can render it. The advantage of exporting a file rather than scraping /api/schema/ is that your docs site does not depend on your API being up. Deployments get decoupled.
Path B: paste your deployed API URL into Docsio. Point it at your live /api/schema/ endpoint or drop the exported schema.yml in. The AI generator scaffolds a branded site with your reference pulled from the schema plus empty conceptual pages you edit in a live preview. Custom domain, one-click publish, and the OpenAPI import are on the free tier. Shorter path if you do not want to run a separate Docusaurus build just to host docs.
Either way, the structure you want on the public site looks like this:
- Quickstart: a one-screen page that gets a developer to their first successful call
- Concepts: authentication, the data model, and any lifecycle rules (invoice state transitions, refund windows, rate limits)
- Guides: two or three common integrations written end to end, like "record a Stripe payment against an invoice"
- Reference: auto-generated from
schema.yml, do not hand-edit
That same split is what we recommend in our API documentation best practices post, and it is the same skeleton used in the FastAPI walkthrough and the Supabase walkthrough in this series.
Step 6: Keep the schema honest
Schema drift is the thing that kills Django documentation six months in. The endpoint changes, the docs do not, and every integrator finds out in production. Three rules keep drift small:
- Run
python manage.py spectacular --validate --fail-on-warn --format openapi-yaml > /dev/nullin CI. A schema that does not validate fails the PR. drf-spectacular is strict here, which is what you want. - Add a pre-commit hook that regenerates
schema.ymlso it is always in sync with the code. Reviewers see the diff. - Cite endpoints in concept pages by path and method, not by prose. "See
POST /api/invoices/{id}/send/" stays accurate when you rename the Python function. "See the send invoice endpoint" does not.
If you are versioning the API (URL-based, header-based, or accept-header), expose one schema per version and publish them side by side. drf-spectacular supports SchemaGenerator subclasses for this. The same versioning tradeoffs we cover in OpenAPI documentation and SDK documentation apply once your Django project has real customers on multiple versions.
What to do next
The walkthrough above gets you a complete documented Django REST Framework backend: schema generation, model and serializer docs, viewset descriptions, a documented JWT auth flow, and a published docs site. If your API is still growing, the obvious next moves are:
- Auto-publish on deploy. Add a GitHub Actions step that calls
manage.py spectacular, commits the result, and triggers a docs rebuild. - Version the API before you need to. Copy your current URL routes under
/api/v1/, expose a separate schema endpoint for each version, and you never have to do the painful cutover later. - Generate a client SDK from
schema.ymlusingopenapi-generator-cli. Ship a Python or TypeScript client alongside the docs and you remove a whole category of integration mistakes. - Audit the schema quarterly. Open the ReDoc page, scroll every endpoint, and kill anything deprecated. The schema is only as useful as the most recent sweep.
If you want to skip the hosting step and see a branded docs site built from your schema without standing up a separate Docusaurus project, import your OpenAPI schema into Docsio and you will have a published, branded site on a custom domain in under ten minutes. Free tier includes the import, the custom domain, and publish.
