Django REST Framework

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1 on PyPI, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, and against Django REST framework (DRF) 3.18.1 (released 2026-09-07, the first line to declare Django 6.1 support; it supports Django 5.2, 6.0 and 6.1 and Python 3.10 or newer), all checked on 2026-10-03. The companion packages are django-filter 26.1 (2026-07-11, classified for Django 6.1), drf-spectacular 0.30.0 (2026-07-06) and djangorestframework-simplejwt 5.5.1 (2025-07-21); the last two only declare Django up to 6.0 and 5.2 respectively, but both were installed and exercised against Django 6.1.1 for this page. On a Django 5.2 LTS project the same DRF release applies unchanged. Every listing was run in a scratch project on Python 3.13 with SQLite: manage.py check (also under -W error) passes, manage.py spectacular --validate --fail-on-warn produces a valid schema for each API version, a suite of 17 APITestCase tests passes, and every curl session shown was captured against runserver.

Django on its own can answer with JSON (JsonResponse, see Responses, Streaming, CSV & PDF), but a real API needs much more: turning model instances into JSON and validating JSON back into model instances, content negotiation, authentication schemes that do not rely on cookies, per-object permissions, rate limits, pagination, filtering, versioning, machine-readable documentation and a test client that speaks JSON. DRF supplies all of these as small, replaceable classes layered on top of Django’s own views, so that everything else on this site — the ORM, authentication, the permission system, settings, middleware — keeps working. In the Bookshelf application that grows across this section the running example is a versioned API for books, authors and reviews: anyone may read published books, staff curate the catalogue, signed-in readers post one review per book, and only a review’s author may change it.

In a hurry: what goes where

Need Rule of thumb Section

Convert models to and from JSON

A ModelSerializer with an explicit fields list; field rules in validate_<field>(), cross-field rules in validate().

Serializers

One endpoint with custom logic

APIView (class) or @api_view (function); return Response, raise DRF exceptions.

Requests, responses and APIView

Standard list/detail endpoints

A concrete generic view such as ListCreateAPIView, or a ModelViewSet plus a router.

Generic views and mixins, ViewSets and routers

A verb that is not CRUD (publish, like, archive)

@action(detail=True, methods=["post"]) on the ViewSet; the router adds the URL.

Extra actions with @action

Who is calling

DEFAULT_AUTHENTICATION_CLASSES: session for the same-site browser, token or JWT for other clients.

Authentication: session, token and JWT

May they do it

DEFAULT_PERMISSION_CLASSES plus view-level permission_classes; has_object_permission() for "only the owner", and always a filtered get_queryset().

Permissions, including object-level

Stop abuse

DEFAULT_THROTTLE_RATES with anon/user rates and named scopes.

Throttling

Long lists

DEFAULT_PAGINATION_CLASS and PAGE_SIZE; cursor pagination for feeds.

Pagination

?status=…​&search=…​&ordering=…​

django-filter’s DjangoFilterBackend with filterset_fields/filterset_class, plus SearchFilter and OrderingFilter.

Filtering, search and ordering

Evolve without breaking clients

URLPathVersioning with ALLOWED_VERSIONS; branch on request.version.

Versioning

Documentation and client generation

drf-spectacular: SpectacularAPIView (OpenAPI 3) and SpectacularSwaggerView.

OpenAPI schemas with drf-spectacular

Tests

APITestCase with self.client (an APIClient), format="json", force_authenticate().

Testing with APIClient

Where DRF sits in the request cycle

DRF does not replace Django’s request handling; it plugs in at the view layer. A request still passes through the middleware stack and the URL resolver described in The Request/Response Cycle, and then reaches a DRF view, which is an ordinary Django view produced by APIView.as_view(). Inside that view DRF runs its own pipeline. APIView.dispatch() first wraps Django’s HttpRequest in a DRF Request (adding request.data, request.query_params, lazy request.user/request.auth), then initial() runs, in this order: content negotiation (which renderer will produce the response), version determination (request.version), authentication, permission checks and throttle checks. Only then is the handler (get(), post(), or the ViewSet action such as list() or create()) called. Any APIException raised on the way — by a permission, a throttle, a serializer or your own code — is turned by the exception handler into a JSON error response, and finalize_response() renders the Response with the negotiated renderer.

sequenceDiagram autonumber participant C as Client participant M as Django middleware and URLconf participant V as APIView.dispatch participant S as Serializer participant D as Database C->>M: POST /api/v1/books/1/reviews/ (JSON, Bearer token) M->>V: HttpRequest plus URL kwargs (version, pk) V->>V: wrap in Request, negotiate renderer V->>V: determine version, authenticate V->>V: check permissions, check throttles alt any check fails V-->>C: 401, 403, 404 or 429 JSON error else all checks pass V->>S: serializer(data=request.data).is_valid() S-->>V: validated_data or errors (400) V->>D: serializer.save() runs the INSERT D-->>V: saved Review V-->>M: Response(serializer.data, 201) M-->>C: rendered JSON end
Figure 1. How a DRF view handles a request

Three consequences follow from this position. First, APIView.as_view() wraps the view in csrf_exempt, so Django’s CsrfViewMiddleware does not reject the request; instead SessionAuthentication performs the CSRF check itself, and only for requests it authenticates from the session cookie (token and JWT requests are not subject to CSRF because browsers do not attach those credentials automatically). Second, authentication is lazy and happens inside the view, which is why Django 5.1’s LoginRequiredMiddleware cannot see DRF’s authentication result: since DRF 3.16 every DRF view is opted out of that middleware, and access control for APIs belongs in permission classes. Third, the version, the user and the throttle state are all available to get_queryset(), get_serializer_class() and get_permissions(), which is what makes the per-version and per-user behaviour on this page possible.

Installing DRF and the running example

DRF and its companions are ordinary packages. rest_framework.authtoken is only needed for the built-in token scheme (it adds a Token model and therefore a migration); rest_framework_simplejwt does not need to be in INSTALLED_APPS unless you use its optional token blacklist.

Installing the packages
python -m pip install "djangorestframework==3.18.*" "django-filter==26.*" \
    "drf-spectacular==0.30.*" "djangorestframework-simplejwt==5.5.*"
python manage.py migrate        # creates authtoken_token
python manage.py check
config/settings.py - installed apps
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "rest_framework",
    "rest_framework.authtoken",
    "django_filters",
    "drf_spectacular",
    "catalog",
]

The models are a trimmed version of the Bookshelf catalogue built in Models & Fields and Relationships: an Author, a Book with a draft/published status, and a Review with a database constraint allowing one review per user and book.

catalog/models.py
from django.conf import settings
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models


class Author(models.Model):
    name = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, unique=True)
    born = models.DateField(null=True, blank=True)

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name


class Book(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "Draft"
        PUBLISHED = "published", "Published"

    title = models.CharField(max_length=300)
    author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name="books")
    isbn = models.CharField("ISBN", max_length=17, unique=True)
    status = models.CharField(max_length=20, choices=Status, default=Status.DRAFT)
    price = models.DecimalField(max_digits=8, decimal_places=2)
    published_on = models.DateField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["title"]

    def __str__(self):
        return self.title


class Review(models.Model):
    book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews"
    )
    rating = models.PositiveSmallIntegerField(
        validators=[MinValueValidator(1), MaxValueValidator(5)]
    )
    text = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]
        constraints = [
            models.UniqueConstraint(
                fields=["book", "user"], name="one_review_per_user_and_book"
            )
        ]

All of DRF’s global configuration lives in one dictionary setting, REST_FRAMEWORK. Every key below is a default that individual views can override with a class attribute (authentication_classes, permission_classes, throttle_classes, pagination_class, filter_backends, versioning_class, schema). The listing is the complete setting used by the running example; each group of keys is explained in its own section below.

config/settings.py - the REST_FRAMEWORK setting
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
        "rest_framework.authentication.TokenAuthentication",
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticatedOrReadOnly",
    ],
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
        "rest_framework.throttling.ScopedRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "100/hour",
        "user": "1000/hour",
        "reviews": "20/day",
    },
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],
    "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
    "DEFAULT_VERSION": "v1",
    "ALLOWED_VERSIONS": ["v1", "v2"],
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}

SPECTACULAR_SETTINGS = {
    "TITLE": "Bookshelf API",
    "DESCRIPTION": "Books, authors and reviews.",
    "VERSION": "1.0.0",
    "SERVE_INCLUDE_SCHEMA": False,
}

Settings are read once, when rest_framework.settings.api_settings is first used. Many classes copy them into class attributes at import time (the throttle classes copy DEFAULT_THROTTLE_RATES, for example), so changing REST_FRAMEWORK with override_settings in a test does not always take effect; Testing with APIClient shows the reliable way. Keep secrets out of this dictionary: the JWT signing key defaults to Django’s SECRET_KEY, which must come from the environment as explained in Settings & Configuration.

Serializers

A serializer does two jobs. Serializing turns a model instance (or any object) into primitive Python data — dicts, lists, strings, numbers — that a renderer then turns into JSON. Deserializing takes untrusted input (request.data), validates it field by field and as a whole, and produces validated_data that save() turns into a created or updated instance. It plays the role a ModelForm plays for HTML (see Model Forms & Formsets), with the same rule: list the fields explicitly.

ModelSerializer and inspecting what it generates

ModelSerializer derives fields, validators and a default create()/update() from the model. Meta.fields is the allow-list of what is read and written; Meta.read_only_fields and Meta.extra_kwargs adjust generated fields without redeclaring them. Calling repr() on a serializer shows exactly which fields it generated, which is the first thing to check when a field behaves unexpectedly.

Inspecting a ModelSerializer and a round trip in the shell
>>> from catalog.api.serializers import AuthorSummarySerializer, ReviewSerializer
>>> from catalog.models import Author
>>> print(repr(ReviewSerializer()))
ReviewSerializer():
    id = BigIntegerField(label='ID', read_only=True)
    book = PrimaryKeyRelatedField(read_only=True)
    user = ReadOnlyField(source='user.username')
    rating = IntegerField(max_value=5, min_value=1)
    text = CharField(allow_blank=True, required=False, style={'base_template': 'textarea.html'})
    created_at = DateTimeField(read_only=True)
>>> AuthorSummarySerializer(Author.objects.get(slug="le-guin")).data
{'id': 1, 'name': 'Ursula K. Le Guin', 'slug': 'le-guin'}
>>> s = AuthorSummarySerializer(data={"name": "N. K. Jemisin", "slug": "le-guin"})
>>> s.is_valid(), s.errors
(False, {'slug': [ErrorDetail(string='author with this slug already exists.', code='unique')]})
>>> s = AuthorSummarySerializer(data={"name": "N. K. Jemisin", "slug": "jemisin"})
>>> s.is_valid(), s.validated_data
(True, {'name': 'N. K. Jemisin', 'slug': 'jemisin'})

The model’s validators came along: MinValueValidator/MaxValueValidator became min_value/max_value, and the unique=True slug became a UniqueValidator.

The serializers module for the running example follows. It shows the common field tools in one place: a nested read-only representation of the author together with a write-only primary key (source="author") so clients send an id but receive an object; a HyperlinkedIdentityField that renders each book’s URL (versioning-aware, see Versioning); read_only_fields so that clients cannot set status (publishing has its own endpoint); an extra_kwargs entry that adds a minimum price without redeclaring the field; and a v2 subclass that adds an annotated average_rating.

catalog/api/serializers.py
from django.db import transaction
from rest_framework import serializers

from catalog.models import Author, Book, Review


class AuthorSerializer(serializers.ModelSerializer):
    books_count = serializers.IntegerField(read_only=True)

    class Meta:
        model = Author
        fields = ["id", "name", "slug", "born", "books_count"]


class AuthorSummarySerializer(serializers.ModelSerializer):
    class Meta:
        model = Author
        fields = ["id", "name", "slug"]


class BookSerializer(serializers.ModelSerializer):
    # nested, read-only representation of the author...
    author = AuthorSummarySerializer(read_only=True)
    # ...and a write-only primary key for creating/updating
    author_id = serializers.PrimaryKeyRelatedField(
        source="author", queryset=Author.objects.all(), write_only=True
    )
    url = serializers.HyperlinkedIdentityField(view_name="book-detail")

    class Meta:
        model = Book
        fields = [
            "id", "url", "title", "isbn", "status", "price",
            "published_on", "author", "author_id",
        ]
        read_only_fields = ["status"]
        extra_kwargs = {"price": {"min_value": 0}}

    def validate_isbn(self, value):
        digits = value.replace("-", "")
        if len(digits) != 13 or not digits.isdigit():
            raise serializers.ValidationError("Enter a 13-digit ISBN.")
        return digits

    def validate(self, attrs):
        published_on = attrs.get("published_on")
        author = attrs.get("author") or getattr(self.instance, "author", None)
        if published_on and author and author.born and published_on < author.born:
            raise serializers.ValidationError(
                {"published_on": "A book cannot be published before its author was born."}
            )
        return attrs


class BookV2Serializer(BookSerializer):
    average_rating = serializers.FloatField(read_only=True)

    class Meta(BookSerializer.Meta):
        fields = BookSerializer.Meta.fields + ["average_rating"]


class ReviewSerializer(serializers.ModelSerializer):
    user = serializers.ReadOnlyField(source="user.username")

    class Meta:
        model = Review
        fields = ["id", "book", "user", "rating", "text", "created_at"]
        read_only_fields = ["book", "created_at"]


class BookInlineSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = ["title", "isbn", "price"]


class AuthorWithBooksSerializer(serializers.ModelSerializer):
    """Writable nested serializer: create an author together with their books."""

    books = BookInlineSerializer(many=True)

    class Meta:
        model = Author
        fields = ["id", "name", "slug", "books"]

    def create(self, validated_data):
        books_data = validated_data.pop("books")
        with transaction.atomic():
            author = Author.objects.create(**validated_data)
            Book.objects.bulk_create(
                Book(author=author, **data) for data in books_data
            )
        return author

The field options used most often:

Option Effect

read_only=True

Included in output, ignored on input (id, timestamps, computed values).

write_only=True

Accepted on input, never rendered (passwords, the author_id above).

required, default, allow_null, allow_blank

Control what may be missing or empty; model fields with blank=True or a default become required=False.

source

The attribute to read from or write to: "author", a dotted path such as "user.username", or "*" for the whole object.

validators

A list of callables run after the field’s own validation.

error_messages

Overrides messages per error code (required, invalid, min_value, …​).

fields = "all" and exclude exist, but they expose every future model column automatically — the API version of the mass-assignment problem that Model Forms & Formsets warns about. Name the fields.

Validation: fields, objects and validators

is_valid() runs validation in a fixed order: each field converts and checks its own value (type, max_length, min_value, choices), then the field’s validators, then a validate_<field_name>(self, value) method if the serializer defines one, and finally validate(self, attrs) with all values together. Field-level methods return the (possibly normalised) value — validate_isbn() above strips hyphens — and object-level validate() returns the whole attrs dict. Raise serializers.ValidationError with a string for a field error, or with a dict to attach the error to a specific field from validate(); a plain string from validate() lands under non_field_errors.

is_valid(raise_exception=True) raises instead of returning False, and DRF’s exception handler turns that into a 400 response whose body is serializer.errors. Object-level validation only runs once every field is valid, so the two error bodies below come from two separate requests:

Field errors, then an object-level error
ACCESS=...   # a staff user's JWT access token, see "Authentication"
curl -s -X POST http://127.0.0.1:8000/api/v1/books/ \
     -H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \
     -d '{"title": "The Lathe of Heaven", "isbn": "123", "price": "-1", "author_id": 1}'
curl -s -X POST http://127.0.0.1:8000/api/v1/books/ \
     -H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \
     -d '{"title": "Early", "isbn": "9780000000019", "price": "1.00", "author_id": 1,
          "published_on": "1900-01-01"}'
{"isbn": ["Enter a 13-digit ISBN."], "price": ["Ensure this value is greater than or equal to 0."]}
{"published_on": ["A book cannot be published before its author was born."]}

Django’s full_clean() is not called by ModelSerializer.save(); the model’s field validators are copied onto the serializer fields, but a model clean() method is not run. Put API-relevant rules in the serializer (or in a function both the serializer and the model call).

Unique constraints and read-only fields

ModelSerializer turns unique=True into a UniqueValidator and a UniqueConstraint over several fields into a UniqueTogetherValidator — but only when every field in the constraint is writable on the serializer. In the running example book and user are read-only (the view supplies them), so DRF generates no validator for one_review_per_user_and_book, and a second review from the same user would reach the database and fail with an IntegrityError (a 500). The DRF validators guide offers two fixes: make the field a HiddenField with a default such as CurrentUserDefault() so it takes part in validation, or check explicitly in the view or serializer. The running example checks in the reviews action and returns a 400; the database constraint stays as the last line of defence.

Second review of the same book from the same user
{"detail": "You have already reviewed this book."}

Saving, partial updates and extra data

serializer.save() calls create(validated_data) when the serializer was built without an instance and update(instance, validated_data) when it was built with one. Keyword arguments to save() are merged into validated_data, which is how the view attaches data the client must not choose: serializer.save(book=book, user=request.user). partial=True (used automatically by PATCH in the generic views) makes every field optional so a client can send only what changes; PUT without partial requires every writable field.

Nested serializers

Nesting a serializer as a field gives a nested JSON object (or, with many=True, a list). Read-only nesting, like author in BookSerializer, needs nothing else. Writable nesting does: DRF refuses to guess how to save related objects, so a serializer with a writable nested field must implement create() (and update() if it supports updates) itself. AuthorWithBooksSerializer creates an author and their books in one request, inside transaction.atomic() so that a failure leaves nothing half-written (see Transactions, Raw SQL & Multiple Databases).

Creating an author with nested books
curl -s -X POST http://127.0.0.1:8000/api/v1/authors/bulk/ \
     -H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \
     -d '{"name": "Octavia E. Butler", "slug": "butler",
          "books": [{"title": "Kindred", "isbn": "9780807083697", "price": "15.00"}]}'

Errors inside a many=True serializer changed format in DRF 3.18.0: they are now a dictionary keyed by the index of each invalid item, and valid items are omitted. Before 3.18 the errors were a list with an empty dict for every valid item. The old format can be restored with "LIST_SERIALIZER_ERRORS_AS_DICT": False (a compatibility setting added in 3.18.1), but it is deprecated and scheduled for removal in 3.20, so clients should be updated instead.

Validation errors for a list of nested books (DRF 3.18 format; the first book was valid)
{"books": {"1": {"title": ["This field may not be blank."],
                 "isbn": ["This field is required."],
                 "price": ["A valid number is required."]}}}

Nested output is also where N+1 queries appear: rendering author for 20 books issues 20 extra queries unless the queryset uses select_related("author"), and nested reverse relations need prefetch_related(). The views below do this in get_queryset(); Query Optimization & Fetch Modes explains the tools.

Requests, responses and APIView

APIView is DRF’s base class-based view. It looks like Django’s View (one method per HTTP verb, see Class-Based Views) but its handlers receive a DRF Request and return a DRF Response:

  • request.data holds the parsed body for any method and any configured parser (JSON, form data, multipart), unlike request.POST, which only handles form data on POST. request.query_params is a clearer name for request.GET. request.user and request.auth come from the authentication classes (request.auth is the token object or None).

  • Response(data, status=…​) takes unrendered Python data; the renderer chosen by content negotiation renders it. With the defaults a browser asking for text/html gets the browsable API and other clients get JSON; ?format=json forces a renderer.

  • Handlers raise DRF exceptions — ValidationError (400), NotAuthenticated (401), PermissionDenied (403), NotFound (404), MethodNotAllowed (405), Throttled (429) — and Django’s own Http404 and PermissionDenied are translated too. Use the constants in rest_framework.status instead of bare numbers.

The statistics endpoint needs no model serializer, so a plain APIView is the right tool. The extend_schema decorator only describes the response for the OpenAPI schema (see OpenAPI schemas with drf-spectacular); the view works without it.

catalog/api/views.py - a plain APIView
from django.db.models import Avg, Count
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework import generics, mixins, permissions, serializers, status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.views import APIView

from catalog.models import Author, Book, Review

from .filters import BookFilter
from .permissions import IsReviewerOrReadOnly
from .serializers import (
    AuthorSerializer,
    AuthorWithBooksSerializer,
    BookSerializer,
    BookV2Serializer,
    ReviewSerializer,
)


class CatalogStatsView(APIView):
    """A plain APIView: full control, no queryset or serializer required."""

    permission_classes = [permissions.AllowAny]

    @extend_schema(
        responses=inline_serializer(
            name="CatalogStats",
            fields={
                "books": serializers.IntegerField(),
                "authors": serializers.IntegerField(),
                "reviews": serializers.IntegerField(),
                "version": serializers.CharField(),
            },
        )
    )
    def get(self, request, version=None, format=None):
        data = {
            "books": Book.objects.filter(status=Book.Status.PUBLISHED).count(),
            "authors": Author.objects.count(),
            "reviews": Review.objects.count(),
            "version": request.version,
        }
        return Response(data)
Content negotiation, OPTIONS and a disallowed method
curl -s http://127.0.0.1:8000/api/v1/stats/
curl -s -o /dev/null -w "%{content_type}\n" -H "Accept: text/html" http://127.0.0.1:8000/api/v1/stats/
curl -s -X OPTIONS http://127.0.0.1:8000/api/v1/stats/
curl -si -X DELETE http://127.0.0.1:8000/api/v1/stats/ | grep -E "^HTTP|^Allow|detail"
{"books": 3, "authors": 1, "reviews": 1, "version": "v1"}
text/html; charset=utf-8
{"name": "Catalog Stats",
 "description": "A plain APIView: full control, no queryset or serializer required.",
 "renders": ["application/json", "text/html"],
 "parses": ["application/json", "application/x-www-form-urlencoded", "multipart/form-data"]}
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD, OPTIONS
{"detail":"Method \"DELETE\" not allowed."}

Function-based views with @api_view

@api_view(["GET", …​]) gives a function the same Request/Response handling. Policy is set with stacked decorators that must sit below @api_view (DRF 3.17 added a check that rejects them in the wrong order): @permission_classes, @authentication_classes, @throttle_classes, @renderer_classes, @parser_classes, @schema, and — new in 3.17 — @versioning_class, @content_negotiation_class and @metadata_class, and — new in 3.18 — @throttle_scope. The ISBN lookup below is public, uses its own throttle scope ("lookups": "60/minute" is added to DEFAULT_THROTTLE_RATES) and lets Django’s get_object_or_404 produce DRF’s 404 body.

catalog/api/fbv.py
from django.shortcuts import get_object_or_404
from drf_spectacular.utils import extend_schema
from rest_framework.decorators import api_view, permission_classes, throttle_scope
from rest_framework.permissions import AllowAny
from rest_framework.response import Response

from catalog.models import Book

from .serializers import BookSerializer


@extend_schema(responses=BookSerializer)
@api_view(["GET"])
@permission_classes([AllowAny])
@throttle_scope("lookups")
def book_by_isbn(request, isbn, version=None):
    book = get_object_or_404(
        Book.objects.select_related("author"),
        isbn=isbn.replace("-", ""),
        status=Book.Status.PUBLISHED,
    )
    serializer = BookSerializer(book, context={"request": request})
    return Response(serializer.data)
Lookup by ISBN, found and not found
curl -s http://127.0.0.1:8000/api/v1/isbn/978-0-06-105488-4/
curl -s http://127.0.0.1:8000/api/v1/isbn/0000/
{"id": 1, "url": "http://127.0.0.1:8000/api/v1/books/1/", "title": "The Dispossessed", "isbn": "9780061054884",
 "status": "published", "price": "9.99", "published_on": "1974-05-01",
 "author": {"id": 1, "name": "Ursula K. Le Guin", "slug": "le-guin"}}
{"detail": "No Book matches the given query."}

Passing context={"request": request} matters: hyperlinked fields need the request to build absolute URLs, and the generic views add it for you through get_serializer().

Generic views and mixins

Most endpoints are "list a queryset", "create one", "retrieve, update or delete one". GenericAPIView adds the attributes and hooks for that — queryset, serializer_class, lookup_field, pagination_class, filter_backends, and the methods get_queryset(), get_object(), get_serializer(), get_serializer_class(), filter_queryset() — and the mixins add the actions. DRF composes them into concrete classes:

Class HTTP methods Built from

ListAPIView

GET (collection)

ListModelMixin

CreateAPIView

POST

CreateModelMixin

RetrieveAPIView

GET (one)

RetrieveModelMixin

UpdateAPIView

PUT, PATCH

UpdateModelMixin

DestroyAPIView

DELETE

DestroyModelMixin

ListCreateAPIView

GET, POST

List + Create

RetrieveUpdateAPIView

GET, PUT, PATCH

Retrieve + Update

RetrieveDestroyAPIView

GET, DELETE

Retrieve + Destroy

RetrieveUpdateDestroyAPIView

GET, PUT, PATCH, DELETE

Retrieve + Update + Destroy

catalog/api/views.py - generic views for authors
class AuthorList(generics.ListCreateAPIView):
    queryset = Author.objects.annotate(books_count=Count("books"))
    serializer_class = AuthorSerializer
    search_fields = ["name"]
    ordering_fields = ["name", "born"]


class AuthorDetail(generics.RetrieveUpdateDestroyAPIView):
    queryset = Author.objects.annotate(books_count=Count("books"))
    serializer_class = AuthorSerializer
    lookup_field = "slug"
    permission_classes = [permissions.IsAdminUser | ReadOnly]


class AuthorWithBooksCreate(generics.CreateAPIView):
    serializer_class = AuthorWithBooksSerializer
    permission_classes = [permissions.IsAdminUser]
catalog/api/permissions.py
from rest_framework import permissions


class IsReviewerOrReadOnly(permissions.BasePermission):
    """Anyone may read a review; only the user who wrote it may change it."""

    def has_object_permission(self, request, view, obj):
        if request.method in permissions.SAFE_METHODS:
            return True
        return obj.user == request.user


class ReadOnly(permissions.BasePermission):
    def has_permission(self, request, view):
        return request.method in permissions.SAFE_METHODS
Listing authors with a search term
curl -s "http://127.0.0.1:8000/api/v1/authors/?search=guin"
{"count": 1, "next": null, "previous": null,
 "results": [{"id": 1, "name": "Ursula K. Le Guin", "slug": "le-guin", "born": "1929-10-21", "books_count": 3}]}

The rules that keep generic views correct:

  • Override get_queryset(), never mutate queryset. The class attribute is evaluated once and cached across requests; get_queryset() runs per request and can use self.request.user and self.request.version. It is also the place for select_related(), prefetch_related() and annotate() (the books_count above), which the generic views guide calls out for avoiding N+1 queries.

  • Hooks instead of rewriting actions. perform_create(serializer), perform_update(serializer) and perform_destroy(instance) are the places to call serializer.save(owner=self.request.user), enqueue a task after commit, or write an audit entry.

  • get_object() applies object permissions. It looks the object up by lookup_field (pk by default, slug above) within get_queryset(), raises 404 if absent, and calls check_object_permissions(). A custom action that loads an object any other way skips those checks.

  • Combining permission classes. IsAdminUser | ReadOnly composes permissions with Python’s bitwise operators (&, |, ~), the same technique the permissions guide uses to rebuild IsAuthenticatedOrReadOnly. Setting permission_classes on a view replaces the default list from settings; it does not add to it.

ViewSets and routers

A ViewSet groups the handlers for one resource in a single class, named after actions rather than HTTP methods: list(), create(), retrieve(), update(), partial_update() and destroy(). A router then generates the URL patterns and maps methods to actions (GET on the collection is list, POST is create, GET on an item is retrieve, and so on). The classes available:

Class Provides

ViewSet

No actions and no queryset handling: write list(), retrieve()…​ yourself (an APIView with action routing).

GenericViewSet

GenericAPIView behaviour, no actions; add the mixins you need (the ReviewViewSet below has retrieve, update and destroy, but no list or create).

ReadOnlyModelViewSet

list() and retrieve().

ModelViewSet

All six actions.

Inside a ViewSet self.action names the current action, which is how one class can choose a serializer, a queryset, permissions or throttles per action. The BookViewSet below is the centre of the API: public reads of published books (staff also see drafts), staff-only writes, a different serializer and an annotated queryset for v2, and two extra actions.

catalog/api/views.py - the book ViewSet
class BookViewSet(viewsets.ModelViewSet):
    filterset_class = BookFilter
    search_fields = ["title", "author__name", "=isbn"]
    ordering_fields = ["title", "price", "published_on"]
    ordering = ["title"]

    def get_queryset(self):
        qs = Book.objects.select_related("author")
        if self.request.version == "v2":
            qs = qs.annotate(average_rating=Avg("reviews__rating"))
        user = self.request.user
        if not (user.is_authenticated and user.is_staff):
            qs = qs.filter(status=Book.Status.PUBLISHED)
        return qs

    def get_serializer_class(self):
        if self.action == "reviews":
            return ReviewSerializer
        if self.request.version == "v2":
            return BookV2Serializer
        return BookSerializer

    def get_permissions(self):
        if self.action in {"create", "update", "partial_update", "destroy", "publish"}:
            return [permissions.IsAdminUser()]
        return super().get_permissions()

    def get_throttles(self):
        if self.action == "reviews" and self.request.method == "POST":
            self.throttle_scope = "reviews"
        return super().get_throttles()

    @action(detail=True, methods=["post"])
    def publish(self, request, pk=None, version=None):
        book = self.get_object()
        book.status = Book.Status.PUBLISHED
        book.save(update_fields=["status"])
        return Response({"status": book.status})

    @action(detail=True, methods=["get", "post"])
    def reviews(self, request, pk=None, version=None):
        book = self.get_object()
        if request.method == "GET":
            page = self.paginate_queryset(book.reviews.select_related("user"))
            serializer = self.get_serializer(page, many=True)
            return self.get_paginated_response(serializer.data)
        if book.reviews.filter(user=request.user).exists():
            raise ValidationError({"detail": "You have already reviewed this book."})
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.save(book=book, user=request.user)
        return Response(serializer.data, status=status.HTTP_201_CREATED)

The routers are registered in the app’s API URLconf, which the project includes under a versioned prefix (see Versioning). DefaultRouter additionally serves an API root view listing the registered resources and supports format suffixes; SimpleRouter generates only the resource routes. basename is the prefix of the generated URL names; it is required when the ViewSet has no queryset attribute (all three here compute it in get_queryset()).

catalog/api/urls.py
from django.urls import include, path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from rest_framework.routers import DefaultRouter

from . import fbv, views

router = DefaultRouter()
router.register("books", views.BookViewSet, basename="book")
router.register("reviews", views.ReviewViewSet, basename="review")
router.register("me/reviews", views.MyReviewsViewSet, basename="my-review")

urlpatterns = [
    path("isbn/<str:isbn>/", fbv.book_by_isbn, name="book-by-isbn"),
    path("stats/", views.CatalogStatsView.as_view(), name="catalog-stats"),
    path("authors/", views.AuthorList.as_view(), name="author-list"),
    path("authors/bulk/", views.AuthorWithBooksCreate.as_view(), name="author-bulk"),
    path("authors/<slug:slug>/", views.AuthorDetail.as_view(), name="author-detail"),
    path("schema/", SpectacularAPIView.as_view(), name="schema"),
    path("docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="api-docs"),
    path("", include(router.urls)),
]
config/urls.py
from django.contrib import admin
from django.urls import include, path, re_path
from rest_framework.authtoken.views import obtain_auth_token
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

from catalog.api.fbv import retired_api

urlpatterns = [
    path("admin/", admin.site.urls),
    re_path(r"^api/(?P<version>v1|v2)/", include("catalog.api.urls")),
    re_path(r"^api/v0/", retired_api, name="api-v0-retired"),
    path("api/auth/token/", obtain_auth_token, name="api-token"),
    path("api/auth/jwt/", TokenObtainPairView.as_view(), name="jwt-obtain"),
    path("api/auth/jwt/refresh/", TokenRefreshView.as_view(), name="jwt-refresh"),
    path("api-auth/", include("rest_framework.urls")),
]

The router generated these routes and names (shown without the format-suffix variants):

Path (under /api/<version>/) Name Actions

books/

book-list

GET list, POST create

books/<pk>/

book-detail

GET retrieve, PUT update, PATCH partial_update, DELETE destroy

books/<pk>/publish/

book-publish

POST publish

books/<pk>/reviews/

book-reviews

GET and POST reviews

reviews/<pk>/

review-detail

GET, PUT, PATCH, DELETE

me/reviews/

my-review-list

GET list

me/reviews/<pk>/

my-review-detail

GET retrieve

(empty)

api-root

GET, the browsable index

Router URLs are regular expressions by default and match any lookup value without slashes or periods, so primary keys and slugs both work without configuration. SimpleRouter(use_regex_path=False) (or DefaultRouter) generates path() converters instead (str by default since 3.15). Restrict the lookup with lookup_value_regex on the ViewSet, or lookup_value_converter when using converters. Order matters among the hand-written paths: authors/bulk/ must be listed before authors/<slug:slug>/, or the slug pattern would capture bulk. The schema and Swagger UI views sit inside the versioned prefix so that each version gets its own schema (see OpenAPI schemas with drf-spectacular).

Extra actions with @action

Not every operation is CRUD. @action marks a ViewSet method as routable; the router adds a URL for it and names it <basename>-<url_name>. Its arguments:

Argument Meaning

detail

Required. True for an item route (books/<pk>/publish/), False for a collection route (books/recent/).

methods

HTTP methods, default ["get"]; http.HTTPMethod members are accepted too (documented since 3.15).

url_path, url_name

Override the URL segment (default: the method name, underscores kept) and the route name (default: the method name with underscores turned into dashes).

other keyword arguments

Set as attributes on the view for that action only, for example permission_classes=[…​], serializer_class=…​, throttle_classes=[…​], filter_backends=[…​].

An action that accepts several methods can route each to a separate method with @<action>.mapping.post, and self.reverse_action("book-publish", args=[pk]) (or reverse() with the route name) builds its URL.

Publishing a draft and posting a review
curl -s -X POST http://127.0.0.1:8000/api/v1/books/3/publish/ -H "Authorization: Bearer $ACCESS"
curl -s -X POST http://127.0.0.1:8000/api/v1/books/1/reviews/ \
     -H "Authorization: Bearer $ACCESS" -H "Content-Type: application/json" \
     -d '{"rating": 4, "text": "Still relevant."}'
{"status": "published"}
{"id": 2, "book": 1, "user": "ada", "rating": 4, "text": "Still relevant.", "created_at": "2026-10-03T06:57:06.297528Z"}

Two traps showed up while building this example. First, keyword arguments to @action only change the view’s attributes; if the ViewSet overrides get_serializer_class() or get_permissions() without consulting them, they are silently ignored. The first version of reviews passed serializer_class=ReviewSerializer while get_serializer_class() returned the book serializer unconditionally, so posting a review failed with "title: This field is required". The fix is to branch on self.action (as above) or to call super() for actions you do not handle. Second, an action that loads its object must use self.get_object(), which applies get_queryset() (drafts are invisible to the public) and the object-level permissions; Book.objects.get(pk=pk) would bypass both.

Older code uses @detail_route and @list_route. DRF 3.8 merged them into @action(detail=True) and @action(detail=False) and deprecated the old names, which no longer exist in current releases, so such code fails at import time with an ImportError. The replacement is mechanical: @detail_route(methods=["post"]) becomes @action(detail=True, methods=["post"]).

Authentication: session, token and JWT

Authentication answers "who is calling"; it does not refuse anything by itself. DRF tries each class in DEFAULT_AUTHENTICATION_CLASSES (or the view’s authentication_classes) in order; the first that recognises credentials sets request.user and request.auth, and if none does, request.user is an AnonymousUser. Invalid credentials (a bad token, an expired JWT) produce an immediate 401 instead of falling through. Permission classes then decide what the identified user may do (see Permissions, including object-level). The three schemes on this page:

Scheme Credential Good for Watch out for

SessionAuthentication

Django’s session cookie, from a normal login.

A JavaScript front end served from the same site, and the browsable API.

CSRF: unsafe methods need the X-CSRFToken header. Cross-site use needs CORS and cookie settings.

TokenAuthentication

Authorization: Token <key>; one random key per user in the authtoken_token table.

Simple server-to-server clients and scripts.

Keys do not expire and are stored in plain text; one key per user, shared by all their devices.

JWTAuthentication (djangorestframework-simplejwt)

Authorization: Bearer <access JWT>; a short-lived signed access token plus a refresh token.

Mobile and single-page apps on other origins, and services that verify tokens without a database lookup.

Access tokens cannot be revoked before they expire; keep them short and protect the signing key.

401 or 403: why the order of the classes matters

An unauthenticated request that is refused should get 401 with a WWW-Authenticate header telling the client how to authenticate; an authenticated request that is refused gets 403. DRF can only send one WWW-Authenticate header, so it uses the first authentication class: SessionAuthentication has no such header, so with it first anonymous clients receive 403. The running example lists JWTAuthentication first, so anonymous writes get a proper 401 (the first test suite run of this page failed on exactly this assertion with session listed first).

An anonymous write
curl -si -X POST http://127.0.0.1:8000/api/v1/books/ -H "Content-Type: application/json" -d '{}' \
     | grep -E "^HTTP|^WWW-Authenticate|detail"
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api"
{"detail":"Authentication credentials were not provided."}

Session authentication and CSRF

With SessionAuthentication, users log in through Django’s normal login (see Authentication); path("api-auth/", include("rest_framework.urls")) adds login and logout views for the browsable API (logout is a POST since DRF 3.15). Because the browser sends the cookie automatically, every unsafe request authenticated this way must pass Django’s CSRF check — DRF enforces it inside SessionAuthentication, even though the view itself is csrf_exempt. A JavaScript client reads the csrftoken cookie and sends it as X-CSRFToken, as described in Security. If the front end lives on another origin, CORS must allow it and the session and CSRF cookies need suitable SameSite/Secure settings; see CORS for the theory.

A session-authenticated POST without a CSRF token is refused
def test_session_auth_enforces_csrf(self):
    client = APIClient(enforce_csrf_checks=True)
    client.login(username="alice", password="pw-alice")
    url = reverse("book-reviews", kwargs={"version": "v1", "pk": self.book.pk})
    response = client.post(url, {"rating": 5}, format="json")
    self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
    self.assertIn("CSRF", str(response.data["detail"]))

Token authentication

rest_framework.authtoken stores one key per user. Create keys with manage.py drf_create_token <username>, in the admin, from a post_save signal, or by exposing obtain_auth_token, which exchanges a username and password for the user’s key (wired at api/auth/token/ above). Clients send it with the Token keyword:

Obtaining and using a token
curl -s -X POST http://127.0.0.1:8000/api/auth/token/ -d username=alice -d password="$ALICE_PASSWORD"
# {"token": "<40 hexadecimal characters>"}
curl -s http://127.0.0.1:8000/api/v1/me/reviews/ -H "Authorization: Token $TOKEN"

Only send tokens over HTTPS, never put them in the query string (they end up in logs and browser history), and treat the table as a credential store. When you need expiry, several tokens per user or revocation per device, use a third-party package listed in the DRF guide (for example django-rest-knox) or JWTs.

JWT with djangorestframework-simplejwt

DRF has no built-in JWT support; the DRF authentication guide points to third-party packages, and djangorestframework-simplejwt is the common choice. A client posts its username and password to TokenObtainPairView and receives an access token (sent on every request) and a refresh token (sent only to TokenRefreshView to get a new access token). The tokens are signed JWTs, by default HS256 with Django’s SECRET_KEY, so the server verifies them without a database query. The protocol background — claims, signatures, why access tokens are short-lived and how refresh-token rotation detects theft — is covered in JWT and JOSE and Access and Refresh Tokens.

config/settings.py - simplejwt with refresh-token rotation and blacklisting
from datetime import timedelta

INSTALLED_APPS += ["rest_framework_simplejwt.token_blacklist"]

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=1),
    "ROTATE_REFRESH_TOKENS": True,
    "BLACKLIST_AFTER_ROTATION": True,
    "AUTH_HEADER_TYPES": ("Bearer",),
}

The two lifetimes shown are the package defaults. ROTATE_REFRESH_TOKENS makes the refresh endpoint return a new refresh token each time, and BLACKLIST_AFTER_ROTATION (which needs the token_blacklist app and its migrations) records the old one so it cannot be reused. The view wiring is the three path() entries for TokenObtainPairView and TokenRefreshView in config/urls.py above.

Obtaining, using and refreshing JWTs
curl -s -X POST http://127.0.0.1:8000/api/auth/jwt/ -H "Content-Type: application/json" \
     -d "{\"username\": \"alice\", \"password\": \"$ALICE_PASSWORD\"}"
# {"refresh": "eyJhbGciOi...", "access": "eyJhbGciOi..."}
curl -s http://127.0.0.1:8000/api/v1/me/reviews/ -H "Authorization: Bearer $ACCESS"
curl -s -X POST http://127.0.0.1:8000/api/auth/jwt/refresh/ -H "Content-Type: application/json" \
     -d "{\"refresh\": \"$REFRESH\"}"
# {"access": "...", "refresh": "..."}      (a new pair, because of rotation)
curl -s -X POST http://127.0.0.1:8000/api/auth/jwt/refresh/ -H "Content-Type: application/json" \
     -d "{\"refresh\": \"$REFRESH\"}"     # the same, already used refresh token
{"detail": "Token is blacklisted", "code": "token_not_valid"}

The decoded payload of an access token from this run was {"token_type": "access", "exp": 1791011458, "iat": 1791011158, "jti": "…​", "user_id": "2"} — 300 seconds of validity and nothing secret. A JWT is signed, not encrypted: never put personal data in it. A forged or corrupted token is rejected with a 401:

{"detail": "Given token not valid for any token type", "code": "token_not_valid",
 "messages": [{"token_class": "AccessToken", "token_type": "access", "message": "Token is invalid"}]}

Store tokens carefully on the client: an access token in JavaScript memory and a refresh token in an HttpOnly cookie or the platform’s secure storage are the usual compromise. If the front end is same-site, session authentication avoids token storage problems entirely. For "log in with Google" or a full OAuth 2.0 authorisation server, DRF’s guide lists django-oauth-toolkit and similar packages; the flows themselves are explained in OAuth.

Permissions, including object-level

Permissions run after authentication and before throttling and the handler. Each permission class has two hooks: has_permission(request, view), checked for every request in initial(), and has_object_permission(request, view, obj), checked only when the view calls get_object() (or check_object_permissions() explicitly). A request must satisfy every class in the list. A refusal raises NotAuthenticated (401 or 403, see 401 or 403: why the order of the classes matters) for anonymous users and PermissionDenied (403) otherwise.

Class Allows

AllowAny

Everyone (state it explicitly on public endpoints so the intent is visible).

IsAuthenticated

Authenticated users only.

IsAdminUser

Users with is_staff=True.

IsAuthenticatedOrReadOnly

Everyone for safe methods (GET, HEAD, OPTIONS); authenticated users for the rest.

DjangoModelPermissions

Maps POST/PUT/PATCH/DELETE to Django’s add_/change_/delete_ model permissions; needs a queryset or get_queryset(). GET is allowed to any authenticated user unless you extend perms_map.

DjangoModelPermissionsOrAnonReadOnly

As above, plus read-only access for anonymous users.

DjangoObjectPermissions

Per-object model permissions through an object-permission backend such as django-guardian.

The reviews endpoints show the object-level pattern. IsReviewerOrReadOnly (listed with the author views above) lets anyone read a review and only its author change or delete it; IsAuthenticatedOrReadOnly in front of it turns anonymous writes into a 401 before the object is even loaded.

catalog/api/views.py - object-level permission and a per-user queryset
class ReviewViewSet(
    mixins.RetrieveModelMixin,
    mixins.UpdateModelMixin,
    mixins.DestroyModelMixin,
    viewsets.GenericViewSet,
):
    queryset = Review.objects.select_related("user", "book")
    serializer_class = ReviewSerializer
    permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsReviewerOrReadOnly]


class MyReviewsViewSet(viewsets.ReadOnlyModelViewSet):
    """Only ever the current user's reviews -- never everyone's."""

    serializer_class = ReviewSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        if getattr(self, "swagger_fake_view", False):  # schema generation
            return Review.objects.none()
        return Review.objects.filter(user=self.request.user).select_related("user")
Bob tries to change Alice’s review, then Alice does
def test_review_object_permission(self):
    review = Review.objects.create(book=self.book, user=self.alice, rating=3)
    url = reverse("review-detail", kwargs={"version": "v1", "pk": review.pk})
    self.client.force_authenticate(user=self.bob)
    self.assertEqual(
        self.client.patch(url, {"rating": 1}, format="json").status_code,
        status.HTTP_403_FORBIDDEN,
    )
    self.client.force_authenticate(user=self.alice)
    response = self.client.patch(url, {"rating": 5}, format="json")
    self.assertEqual(response.data["rating"], 5)

Object permissions have two documented limitations that matter for security. They are not applied to list responses — the generic views do not call has_object_permission() for each row, for performance — and they are not applied on create, because there is no object yet. So "users may only see their own records" must be enforced in get_queryset(), which is what MyReviewsViewSet does: it never returns other users' reviews, and a request for someone else’s review id gets a 404 rather than revealing that it exists. Restrictions on creation belong in the serializer or in perform_create().

This is precisely the mistake in one of the books (Marani, ch. 6): a customer-facing ModelViewSet whose queryset was Order.objects.all(), guarded only by a permission class, so every authenticated customer could list everyone’s orders. The correction is the pattern above: scope get_queryset() to request.user (and keep object-level checks for writes). The swagger_fake_view guard is for drf-spectacular, which calls get_queryset() without a real user while generating the schema (see OpenAPI schemas with drf-spectacular).

Django’s own permission model — has_perm(), groups, PermissionRequiredMixin and object-permission backends — is covered in Permissions & Authorization; DRF’s DjangoModelPermissions and DjangoObjectPermissions are thin adapters over that same machinery.

Throttling

Throttles limit how often a client may call the API; they run after permissions. Each throttle class computes a cache key for the request (the user id, or the client IP for anonymous requests), keeps the timestamps of recent requests in Django’s cache and raises Throttled (HTTP 429, with a Retry-After header) once the rate is exceeded. Rates are written as "<number>/<period>", where only the first letter of the period counts (s, m, h, d).

Class Throttles by

AnonRateThrottle

Client IP, for unauthenticated requests only; rate key anon.

UserRateThrottle

User id (IP for anonymous users); rate key user.

ScopedRateThrottle

User id or IP, per throttle_scope named on the view; rate key = the scope name. Views without a throttle_scope are not throttled by it.

The running example combines all three: modest global anon/user rates, a reviews scope of 20 per day for posting reviews (set dynamically in BookViewSet.get_throttles() so that reading reviews is not limited), and a lookups scope for the ISBN lookup (set with the @throttle_scope decorator). Exceeding a rate looks like this:

{"detail": "Request was throttled. Expected available in 60 seconds."}

Things to know before relying on throttles:

  • They are not a security boundary. The built-in implementations are open to race conditions under concurrency and the guide says so; use them for fair use and cost control, and put hard limits (login brute force, abusive IPs) in the proxy or a dedicated tool.

  • The cache must be shared. The default LocMemCache is per process, so with several Gunicorn workers each worker counts separately. Use Redis or Memcached in production (see Caching), or set a custom throttle’s cache attribute to another alias.

  • Client IPs behind a proxy. Throttles use X-Forwarded-For when present, which a client can forge. Set NUM_PROXIES to the number of trusted proxies in front of the application so DRF picks the right address.

  • Testing. Rates are copied into SimpleRateThrottle.THROTTLE_RATES when DRF is imported, so override_settings(REST_FRAMEWORK=…​) does not change them; patch the dictionary instead, and clear the cache between tests, as in this test from the suite:

Testing a scoped throttle
def test_review_posting_is_throttled(self):
    self.client.force_authenticate(user=self.alice)
    url = reverse("book-reviews", kwargs={"version": "v1", "pk": self.book.pk})
    rates = {"anon": None, "user": None, "reviews": "1/day"}
    with mock.patch.dict(ScopedRateThrottle.THROTTLE_RATES, rates):
        self.client.post(url, {"rating": 5}, format="json")
        response = self.client.post(url, {"rating": 4}, format="json")
    self.assertEqual(response.status_code, status.HTTP_429_TOO_MANY_REQUESTS)
    self.assertIn("Retry-After", response.headers)

Two Scoops of Django recommends throttling every public API and documenting the limits; that advice still stands, and the rates belong in the OpenAPI description so clients can plan for them.

Pagination

Generic list views and ViewSets paginate automatically when DEFAULT_PAGINATION_CLASS and PAGE_SIZE are set (or a view sets pagination_class). Custom actions must call self.paginate_queryset() and self.get_paginated_response() themselves, as the reviews action does. DRF ships three styles:

Class Query parameters Use when

PageNumberPagination

?page=2; optional page_size_query_param with max_page_size.

Numbered pages and a total count are wanted.

LimitOffsetPagination

?limit=20&offset=40; max_limit.

Clients want arbitrary windows (spreadsheets, exports).

CursorPagination

An opaque ?cursor=…​; requires a stable, unique ordering.

Large or fast-changing feeds: no COUNT(*), no skipped or repeated rows when data changes, constant cost per page.

Every list response in the running example has the page-number envelope (count, next, previous, results) shown in the earlier listings. A custom subclass is the usual way to let clients choose a page size within limits:

catalog/api/pagination.py - a client-sizable page
from rest_framework.pagination import PageNumberPagination


class BookPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = "page_size"
    max_page_size = 100

Page-number pagination runs a COUNT(*) on every request, and deep OFFSET`s get slower as the table grows; for infinite scroll prefer `CursorPagination. Django’s own Paginator and AsyncPaginator, the trade-offs between the styles and a cursor-paginated DRF example are in Pagination.

Filtering, search and ordering

Filter backends narrow the queryset from query parameters; they run in filter_queryset(), after get_queryset(), so they can never widen what a user is allowed to see. Three backends cover most needs, and all are enabled globally in the running example through DEFAULT_FILTER_BACKENDS:

  • django_filters.rest_framework.DjangoFilterBackend (from django-filter) for exact and range filters on fields: filterset_fields = ["status", "author"] for simple equality, or a FilterSet class in filterset_class for lookups such as gte/lte, renamed parameters and validation of the values;

  • rest_framework.filters.SearchFilter for ?search=…​ across search_fields, where a prefix picks the lookup: none for icontains, ^ for istartswith, = for iexact, $ for iregex and @ for PostgreSQL full-text search. DRF 3.18 added UnaccentedSearchFilter, which wraps the lookups in PostgreSQL’s unaccent (so "Jeremy" matches "Jérémy");

  • rest_framework.filters.OrderingFilter for ?ordering=price or ?ordering=-price, restricted to ordering_fields, with the view’s ordering as the default.

catalog/api/filters.py
import django_filters

from catalog.models import Book


class BookFilter(django_filters.FilterSet):
    min_price = django_filters.NumberFilter(field_name="price", lookup_expr="gte")
    max_price = django_filters.NumberFilter(field_name="price", lookup_expr="lte")
    published_after = django_filters.DateFilter(
        field_name="published_on", lookup_expr="gte"
    )

    class Meta:
        model = Book
        fields = ["status", "author"]

The BookViewSet above uses it with filterset_class = BookFilter, search_fields = ["title", "author__name", "=isbn"] and ordering_fields = ["title", "price", "published_on"].

Combining a filter, a search and an ordering; an invalid filter value
curl -s "http://127.0.0.1:8000/api/v1/books/?max_price=10&search=earthsea&ordering=-price"
curl -s "http://127.0.0.1:8000/api/v1/books/?status=nonsense"
{"count": 1, "next": null, "previous": null,
 "results": [{"id": 2, "url": "http://127.0.0.1:8000/api/v1/books/2/", "title": "A Wizard of Earthsea",
              "isbn": "9780547773742", "status": "published", "price": "7.00", "published_on": "1968-11-01",
              "author": {"id": 1, "name": "Ursula K. Le Guin", "slug": "le-guin"}}]}
{"status": ["Select a valid choice. nonsense is not one of the available choices."]}

Rules of thumb: always list ordering_fields explicitly (the default lets clients sort by any serializer field, and "all" by any model field, including ones you may not want to expose); index the columns clients filter and sort on; and avoid $ (regex) search on public endpoints, which the DRF guide warns can be abused for expensive queries. Search across a to-many relation can produce duplicate rows; SearchFilter then adds distinct(), which makes the query more expensive (a foreign key such as author__name does not need it).

Books from the Django 2 era use filter_fields and filter_class on views. django-filter 2.0 renamed them to filterset_fields and filterset_class, and the old names are simply ignored today — a view using them silently filters nothing. The DjangoFilterBackend that once lived in DRF itself was removed in DRF 3.7 in favour of django_filters.rest_framework.DjangoFilterBackend.

Versioning

An API that has clients outside your repository cannot change shape freely. DRF’s versioning determines a version per request — before authentication, see Where DRF sits in the request cycle — and stores it in request.version; your code branches on it. The schemes differ only in where the client puts the version:

Scheme Client sends Notes

URLPathVersioning

/api/v1/books/

URL kwarg version; visible, cache-friendly, easy to route at the proxy.

NamespaceVersioning

/api/v1/books/

The same URLs, but the version comes from the URL namespace of an include().

AcceptHeaderVersioning

Accept: application/json; version=1.0

Clean URLs; harder to try in a browser.

HostNameVersioning

v1.api.example.com

Needs DNS and TLS per version.

QueryParameterVersioning

/api/books/?version=v1

Easy, but easy to forget.

The running example uses URLPathVersioning with ALLOWED_VERSIONS = ["v1", "v2"] and DEFAULT_VERSION = "v1". The URL pattern only accepts those two versions, so /api/v3/books/ is a plain 404 (with an open <str:version> converter DRF would reject the unknown version with its own 404 error instead). Version 2 adds an average_rating field: get_serializer_class() returns BookV2Serializer and get_queryset() adds the Avg("reviews__rating") annotation only when request.version == "v2", so v1 clients pay nothing for it.

The same book in v2
curl -s http://127.0.0.1:8000/api/v2/books/1/
{"id": 1, "url": "http://127.0.0.1:8000/api/v2/books/1/", "title": "The Dispossessed", "isbn": "9780061054884",
 "status": "published", "price": "9.99", "published_on": "1974-05-01",
 "author": {"id": 1, "name": "Ursula K. Le Guin", "slug": "le-guin"}, "average_rating": 5.0}

Note that the url field says v2: HyperlinkedIdentityField and rest_framework.reverse.reverse() ask the versioning scheme to build URLs, so links stay within the version the client used. Django’s plain django.urls.reverse() does not know about request.version; in API code use DRF’s reverse(…​, request=request).

A versioning policy matters more than the mechanism. Add fields freely (clients must ignore unknown fields); remove, rename or retype a field only in a new version; announce deprecations with a date; and when a version is finally switched off, answer with 410 Gone and a pointer to its successor rather than a confusing 404 — the approach Two Scoops of Django recommends for shutting down an API. In the running example a function-based view in catalog/api/fbv.py serves every retired-v0 URL:

Answering a retired version with 410 Gone (in catalog/api/fbv.py; imports status from rest_framework)
@extend_schema(exclude=True)
@api_view(["GET", "POST", "PUT", "PATCH", "DELETE"])
@permission_classes([AllowAny])
def retired_api(request, *args, **kwargs):
    return Response(
        {"detail": "API v0 was retired on 2026-06-30. Use /api/v2/."},
        status=status.HTTP_410_GONE,
    )
$ curl -si http://127.0.0.1:8000/api/v0/books/ | grep -E "^HTTP|detail"
HTTP/1.1 410 Gone
{"detail":"API v0 was retired on 2026-06-30. Use /api/v2/."}

OpenAPI schemas with drf-spectacular

An OpenAPI 3 document describes every path, parameter, request body, response and security scheme; Swagger UI and Redoc render it as interactive documentation, and code generators turn it into typed clients. DRF’s own rest_framework.schemas.openapi generator is deprecated: the DRF schemas guide says it will be moved out and retired, and recommends drf-spectacular as the full replacement. The even older CoreAPI-based schemas, which Django 2-era books use, were superseded by OpenAPI in DRF 3.10 and removed in DRF 3.17. drf-spectacular introspects serializers, views, filters, pagination, authentication and versioning, and lets you fill the gaps with decorators.

Setup is three pieces, all shown earlier: "drf_spectacular" in INSTALLED_APPS, "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema" in REST_FRAMEWORK plus a SPECTACULAR_SETTINGS dictionary (title, description, version, and SERVE_INCLUDE_SCHEMA: False to keep the schema endpoint out of the schema), and the URL patterns for SpectacularAPIView (the YAML or JSON document) and SpectacularSwaggerView (the UI; SpectacularRedocView is the alternative). Because the API is versioned by URL path, the schema views are mounted inside the versioned prefix: /api/v1/schema/ describes v1 and /api/v2/schema/ describes v2 (including average_rating), each with its own /docs/ page.

The beginning of the v2 schema
curl -s http://127.0.0.1:8000/api/v2/schema/ | head -6
openapi: 3.0.3
info:
  title: Bookshelf API
  version: 1.0.0 (v2)
  description: Books, authors and reviews.
paths:

Introspection works well for generic views and ViewSets, because they declare their serializer. It needs help where the code decides at runtime, and the generator prints a warning or error for each such place. The running example needed three fixes, all shown in the listings above:

  • CatalogStatsView is a plain APIView without a serializer, so drf-spectacular could not guess its response ("unable to guess serializer"); @extend_schema(responses=inline_serializer(…​)) describes it. The same applies to the book_by_isbn function view (@extend_schema(responses=BookSerializer), placed above @api_view).

  • MyReviewsViewSet.get_queryset() uses request.user, which does not exist while the schema is generated; the swagger_fake_view guard returns an empty queryset of the right model.

  • The retired-v0 view should not be documented at all: @extend_schema(exclude=True).

@extend_schema also takes parameters (OpenApiParameter), request, examples, summary, description, tags, operation_id and deprecated; @extend_schema_view applies it to individual ViewSet actions and @extend_schema_field documents a custom serializer field.

Generating the schema as a file in CI catches documentation drift: with --validate the output is checked against the OpenAPI specification, and --fail-on-warn turns every introspection warning into a failure. For a versioned API pass --api-version: without it the generator falls back to DEFAULT_VERSION (v1 here), and with no default the schema contains only the unversioned endpoints, a common surprise the drf-spectacular FAQ describes.

Generating and validating the schema in CI
python manage.py spectacular --api-version v1 --validate --fail-on-warn --file schema-v1.yaml
python manage.py spectacular --api-version v2 --validate --fail-on-warn --file schema-v2.yaml

drf-spectacular 0.30.0 declares support up to Django 6.0 in its package metadata; it generated and validated both schemas against Django 6.1.1 without warnings for this page. Serving Swagger UI from a CDN, as SpectacularSwaggerView does by default, needs those hosts in your Content Security Policy; the drf-spectacular-sidecar package serves the assets yourself (see Content Security Policy).

Testing with APIClient

DRF’s test tools extend Django’s (see Testing for test case classes, the test database and setUpTestData):

Tool Use

APIClient

A django.test.Client that understands format="json", credentials(HTTP_AUTHORIZATION=…​), force_authenticate(user=…​, token=…​) and login(). Responses carry response.data, the unrendered data.

APITestCase, APITransactionTestCase, APISimpleTestCase, APILiveServerTestCase

Django’s test case classes with self.client set to an APIClient.

APIRequestFactory

Builds requests to call a view directly (view(request)), skipping middleware and URL routing; with force_authenticate(request, user=…​).

URLPatternsTestCase

Test case with its own urlpatterns, for testing reusable API apps.

The suite for the running example has 17 tests; the first part shows the patterns. force_authenticate() skips the authentication classes entirely, which keeps tests about permissions fast and focused; the token and JWT tests at the end exercise the real authentication path. Throttle counters live in the cache, so setUp() clears it.

catalog/tests.py (excerpt; the remaining tests appear in the permission, throttling, CSRF and generic view sections)
from unittest import mock

from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.urls import reverse
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.test import APIClient, APITestCase

from catalog.models import Author, Book, Review

User = get_user_model()


class BookshelfAPITests(APITestCase):
    @classmethod
    def setUpTestData(cls):
        cls.admin = User.objects.create_user("ada", password="pw-ada", is_staff=True)
        cls.alice = User.objects.create_user("alice", password="pw-alice")
        cls.bob = User.objects.create_user("bob", password="pw-bob")
        cls.author = Author.objects.create(name="Ursula K. Le Guin", slug="le-guin")
        cls.book = Book.objects.create(
            title="The Dispossessed", author=cls.author, isbn="9780061054884",
            price="9.99", status=Book.Status.PUBLISHED,
        )
        cls.draft = Book.objects.create(
            title="Draft", author=cls.author, isbn="9780000000002", price="1.00"
        )

    def setUp(self):
        cache.clear()  # throttling state lives in the cache

    def test_anonymous_list_is_paginated_and_hides_drafts(self):
        response = self.client.get(reverse("book-list", kwargs={"version": "v1"}))
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data["count"], 1)
        self.assertEqual(response.data["results"][0]["title"], "The Dispossessed")

    def test_v2_adds_average_rating(self):
        Review.objects.create(book=self.book, user=self.alice, rating=4)
        url = reverse("book-detail", kwargs={"version": "v2", "pk": self.book.pk})
        response = self.client.get(url)
        self.assertEqual(response.data["average_rating"], 4.0)
        self.assertIn("/api/v2/books/", response.data["url"])

    def test_anonymous_cannot_create(self):
        url = reverse("book-list", kwargs={"version": "v1"})
        response = self.client.post(url, {"title": "X"}, format="json")
        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)

    def test_non_staff_cannot_create(self):
        self.client.force_authenticate(user=self.alice)
        url = reverse("book-list", kwargs={"version": "v1"})
        response = self.client.post(url, {"title": "X"}, format="json")
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_admin_creates_book_with_validation(self):
        self.client.force_authenticate(user=self.admin)
        url = reverse("book-list", kwargs={"version": "v1"})
        bad = {"title": "Lathe", "isbn": "123", "price": "-1", "author_id": self.author.pk}
        response = self.client.post(url, bad, format="json")
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertEqual(set(response.data), {"isbn", "price"})
        good = dict(bad, isbn="978-0-06-051275-6", price="12.50")
        response = self.client.post(url, good, format="json")
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(response.data["isbn"], "9780060512756")
        self.assertEqual(response.data["author"]["slug"], "le-guin")
        self.assertEqual(response.data["status"], "draft")  # read-only field ignored

    def test_publish_action(self):
        self.client.force_authenticate(user=self.admin)
        url = reverse("book-publish", kwargs={"version": "v1", "pk": self.draft.pk})
        response = self.client.post(url)
        self.assertEqual(response.data, {"status": "published"})

    # ...

    def test_token_authentication(self):
        token = Token.objects.create(user=self.alice)
        client = APIClient()
        client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
        response = client.get(reverse("my-review-list", kwargs={"version": "v1"}))
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_jwt_obtain_and_use(self):
        response = self.client.post(
            reverse("jwt-obtain"), {"username": "alice", "password": "pw-alice"},
            format="json",
        )
        access = response.data["access"]
        self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access}")
        response = self.client.get(reverse("my-review-list", kwargs={"version": "v1"}))
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_unknown_version_is_404(self):
        self.assertEqual(self.client.get("/api/v3/books/").status_code, 404)

    def test_writable_nested_create(self):
        self.client.force_authenticate(user=self.admin)
        payload = {
            "name": "Octavia E. Butler", "slug": "butler",
            "books": [{"title": "Kindred", "isbn": "9780807083697", "price": "15.00"}],
        }
        response = self.client.post(
            reverse("author-bulk", kwargs={"version": "v1"}), payload, format="json"
        )
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(Book.objects.filter(author__slug="butler").count(), 1)
Running the suite
python manage.py test catalog
Found 17 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.................
----------------------------------------------------------------------
Ran 17 tests in 0.949s

OK
Destroying test database for alias 'default'...

Testing tips specific to APIs:

  • Use format="json" (or set "TEST_REQUEST_DEFAULT_FORMAT": "json" in REST_FRAMEWORK); the default is a multipart form, which cannot express nested data or null.

  • Assert on status and body. Check the exact status (201, not "less than 400") and the data that proves the effect, as test_admin_creates_book_with_validation does with the normalised ISBN and the ignored read-only status; a test that only checks for 200 passes for the wrong reasons.

  • Test the negative paths: anonymous (401), wrong user (403), someone else’s object (403 or 404), invalid input (400 with the expected field names), unknown version (404), throttled (429).

  • Test the schema too: running manage.py spectacular --validate --fail-on-warn in CI is a cheap contract test.

Sync and async

DRF 3.18 is synchronous: APIView.dispatch(), authentication, permissions, throttles, serializers and the generic views are all plain functions, and there is no async def handler support. Declaring one does not work — Django sees an async method and treats the view as async, but DRF’s synchronous dispatch never awaits the handler and fails with "Expected a Response, HttpResponse or StreamingHttpResponse to be returned from the view, but received a `<class 'coroutine'>`" (reproduced for this page with DRF 3.18.1).

Under an ASGI server (Uvicorn, Daphne, Granian) that is not a problem for correctness: Django runs synchronous views in a thread, exactly as it runs any sync view, so DRF works unchanged and can be served alongside async Django views and Channels consumers. The cost is a thread per in-flight request and the context switch, as explained in Async Support. If an endpoint must be truly async — many concurrent slow calls to other services, long polling — the options are a native async Django view for that endpoint, Django Ninja (async endpoints with Pydantic schemas), or the third-party adrf package, which the DRF docs list and which adds async APIView/ViewSet variants. For streaming events to browsers see Channels & WebSockets.

Inside DRF code the usual sync rules apply, which also means the classic async pitfalls of the books — synchronous ORM calls on the event loop — cannot happen in a DRF view. Background work triggered by an API call (emails, thumbnails) belongs in a task enqueued after commit; see Tasks Framework.

Where the books are outdated

The four books in this section’s bibliography were written for Django 2.x to 5.x and DRF 3.8 to 3.15. Their DRF chapters are still useful for concepts, but the following no longer apply or must not be copied:

What the book shows What to do with DRF 3.18 Since

@detail_route / @list_route on ViewSets

@action(detail=True) / @action(detail=False); the old decorators no longer exist (ImportError).

Deprecated 3.8

router.register(…​, base_name=…​)

basename=…​.

Deprecated 3.9

filter_fields / filter_class on views

filterset_fields / filterset_class; the old names are silently ignored.

django-filter 2.0

rest_framework.filters.DjangoFilterBackend

django_filters.rest_framework.DjangoFilterBackend.

Removed DRF 3.7

CoreAPI schemas, coreapi dependency, include_docs_urls

OpenAPI 3 with drf-spectacular.

Removed DRF 3.17

Built-in rest_framework.schemas.openapi as the documentation tool

Still present but deprecated; drf-spectacular is the recommended replacement.

DRF docs

A REST_FRAMEWORK listing that does not parse (Melé, ch. 12-13: the settings dictionary as printed is a Python syntax error, so the project does not start)

The dictionary in Installing DRF and the running example: keys are strings, values are lists of dotted paths, every entry ends with a comma; python manage.py check catches the mistake immediately.

 — 

A customer-facing ModelViewSet with queryset = Order.objects.all() (Marani, ch. 6), exposing every customer’s orders to any logged-in user

Scope get_queryset() to request.user (MyReviewsViewSet); object permissions do not filter lists.

 — 

An authentication token passed in the URL query string (Marani, ch. 8)

Send credentials only in the Authorization header; prefer short-lived JWTs with refresh rotation.

 — 

Error format for many=True serializers as a list with empty dicts

A dict keyed by item index (LIST_SERIALIZER_ERRORS_AS_DICT).

DRF 3.18.0

Logout via GET in the browsable API

POST.

DRF 3.15

The book chapters on API design are not outdated: Two Scoops of Django’s advice on keeping API code in its own module (catalog/api/ here), versioning from day one, throttling and documenting the API all still hold.

What changed recently in DRF

Version Date Changes that matter for application code

3.15

2024-03-15

Django 5.0 and Python 3.12; browsable API logout uses POST; str as the default path converter for routers; http.HTTPMethod documented for @action; model field defaults propagated to serializer fields; changed semantics of OR-composed permissions; SearchFilter aligned with the admin’s search; CoreAPI deprecation warnings; pytz dependency dropped.

3.16

2025-03-28

Django 5.1 and 5.2, Python 3.13; DRF views opted out of LoginRequiredMiddleware; Python 3.8 dropped; fixes to unique-together validation with UniqueConstraint conditions.

3.17

2026-03-18

Django 6.0 and Python 3.14; Python 3.9 dropped; CoreAPI support removed; @versioning_class, @content_negotiation_class and @metadata_class decorators for function views; a check for decorators placed in the wrong order around @api_view; UniqueConstraint’s `violation_error_code/violation_error_message honoured; token keys generated with the secrets module. 3.17.2 (2026-08-05) fixed two security issues (data disclosure through the AdminRenderer and DATA_UPLOAD_MAX_MEMORY_SIZE not being enforced for request.data).

3.18

2026-08-07

Django 6.1; Django 4.2, 5.0 and 5.1 dropped; errors of many=True serializers as a dict keyed by index; UnaccentedSearchFilter; the @throttle_scope function-view decorator; nulls_distinct support in UniqueTogetherValidator. 3.18.1 (2026-09-07) added the LIST_SERIALIZER_ERRORS_AS_DICT compatibility setting and rejects nan/inf in FloatField.

Upgrade advice: DRF 3.18 needs Django 5.2 or newer, so a project still on Django 4.2 must stay on 3.17 until it upgrades Django. Run the test suite with -W error::DeprecationWarning to surface the deprecations DRF announces ahead of removals, and check API clients that parse validation errors of list payloads before moving to 3.18.

DRF or Django Ninja?

DRF is the mature, batteries-included choice: class-based views, a large ecosystem (filters, auth packages, drf-spectacular), the browsable API and a decade of documentation, at the cost of being synchronous and somewhat verbose. Django Ninja builds endpoints from type-annotated functions and Pydantic schemas, produces OpenAPI natively and supports async endpoints, in the style of FastAPI; it suits new, typed, API-first projects. Both sit on the same Django models, auth and settings, and can coexist in one project under different URL prefixes. Django Ninja compares them in detail, and FastAPI: Introduction and Architecture covers the FastAPI side.

Pitfalls checklist

  • fields = "all" or exclude on a serializer: new model columns become writable and visible. List fields.

  • A queryset attribute without user scoping on endpoints that return per-user data: object permissions do not filter lists. Override get_queryset().

  • get_serializer_class()/get_permissions() overrides that ignore @action(serializer_class=…​, permission_classes=…​).

  • Custom actions that fetch objects with Model.objects.get() instead of self.get_object(), skipping queryset filtering and object permissions.

  • SessionAuthentication listed first: anonymous clients get 403 without WWW-Authenticate. List the token or JWT class first if non-browser clients matter.

  • Unique constraints over read-only fields: no validator is generated, and duplicates surface as IntegrityError.

  • Missing select_related()/prefetch_related() for nested serializers: N+1 queries on every list page.

  • No pagination on a list endpoint: one request can read the whole table.

  • Throttling with LocMemCache behind several workers, or without NUM_PROXIES behind a proxy.

  • Long-lived bearer credentials: DRF tokens never expire; JWT access tokens cannot be revoked. Keep access tokens short, rotate refresh tokens, and serve everything over HTTPS.

  • Model clean() logic that the API silently skips: serializers do not call full_clean().

  • override_settings(REST_FRAMEWORK=…​) in tests for values DRF caches at import time (throttle rates).

  • Undocumented or drifting schema: generate it in CI with --validate --fail-on-warn.

  • async def handlers on DRF views: not supported in DRF 3.18.