Back to blog
|11 min read|Docsio

How to Document a Django Backend: A DRF Walkthrough

djangopythondocumentationwalkthrough
How to Document a Django Backend: A DRF Walkthrough

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
  • pip or uv for 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 YAML
  • http://localhost:8000/api/docs/ renders Swagger UI
  • http://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:

  1. Quickstart: a one-screen page that gets a developer to their first successful call
  2. Concepts: authentication, the data model, and any lifecycle rules (invoice state transitions, refund windows, rate limits)
  3. Guides: two or three common integrations written end to end, like "record a Stripe payment against an invoice"
  4. 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/null in 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.yml so 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.yml using openapi-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.

Ready to ship your docs?

Generate a complete documentation site from your URL in under 5 minutes.

Get Started Free