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 |
|
One endpoint with custom logic |
|
|
Standard list/detail endpoints |
A concrete generic view such as |
|
A verb that is not CRUD (publish, like, archive) |
|
|
Who is calling |
|
|
May they do it |
|
|
Stop abuse |
|
|
Long lists |
|
|
|
django-filter’s |
|
Evolve without breaking clients |
|
|
Documentation and client generation |
drf-spectacular: |
|
Tests |
|
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.
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.
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
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.
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"
)
]
Source: Django: Models and Django: UniqueConstraint
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.
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,
}
Source: Django REST framework: Settings
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.
>>> 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.
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
Source: Django REST framework: ModelSerializer, Django REST framework: Serializer relations and Django REST framework: Serializer fields
The field options used most often:
| Option | Effect |
|---|---|
|
Included in output, ignored on input ( |
|
Accepted on input, never rendered (passwords, the |
|
Control what may be missing or empty; model fields with |
|
The attribute to read from or write to: |
|
A list of callables run after the field’s own validation. |
|
Overrides messages per error code ( |
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:
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.
{"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).
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.
{"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.dataholds the parsed body for any method and any configured parser (JSON, form data, multipart), unlikerequest.POST, which only handles form data on POST.request.query_paramsis a clearer name forrequest.GET.request.userandrequest.authcome from the authentication classes (request.authis the token object orNone). -
Response(data, status=…)takes unrendered Python data; the renderer chosen by content negotiation renders it. With the defaults a browser asking fortext/htmlgets the browsable API and other clients get JSON;?format=jsonforces a renderer. -
Handlers raise DRF exceptions —
ValidationError(400),NotAuthenticated(401),PermissionDenied(403),NotFound(404),MethodNotAllowed(405),Throttled(429) — and Django’s ownHttp404andPermissionDeniedare translated too. Use the constants inrest_framework.statusinstead 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.
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)
Source: Django REST framework: Class-based views, Requests and Responses
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."}
Source: Django REST framework: Content negotiation and Exceptions
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.
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)
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 |
|---|---|---|
|
GET (collection) |
|
|
POST |
|
|
GET (one) |
|
|
PUT, PATCH |
|
|
DELETE |
|
|
GET, POST |
List + Create |
|
GET, PUT, PATCH |
Retrieve + Update |
|
GET, DELETE |
Retrieve + Destroy |
|
GET, PUT, PATCH, DELETE |
Retrieve + Update + Destroy |
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]
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
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 mutatequeryset. The class attribute is evaluated once and cached across requests;get_queryset()runs per request and can useself.request.userandself.request.version. It is also the place forselect_related(),prefetch_related()andannotate()(thebooks_countabove), which the generic views guide calls out for avoiding N+1 queries. -
Hooks instead of rewriting actions.
perform_create(serializer),perform_update(serializer)andperform_destroy(instance)are the places to callserializer.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 bylookup_field(pkby default,slugabove) withinget_queryset(), raises 404 if absent, and callscheck_object_permissions(). A custom action that loads an object any other way skips those checks. -
Combining permission classes.
IsAdminUser | ReadOnlycomposes permissions with Python’s bitwise operators (&,|,~), the same technique the permissions guide uses to rebuildIsAuthenticatedOrReadOnly. Settingpermission_classeson 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 |
|---|---|
|
No actions and no queryset handling: write |
|
|
|
|
|
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.
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()).
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)),
]
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")),
]
Source: Django REST framework: Routers
The router generated these routes and names (shown without the format-suffix variants):
Path (under /api/<version>/) |
Name | Actions |
|---|---|---|
|
|
GET |
|
|
GET |
|
|
POST |
|
|
GET and POST |
|
|
GET, PUT, PATCH, DELETE |
|
|
GET |
|
|
GET |
(empty) |
|
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 |
|---|---|
|
Required. |
|
HTTP methods, default |
|
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 |
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.
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"}
Source: Django REST framework: Marking extra actions for routing and Routing additional HTTP methods for extra actions
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 |
|---|---|---|---|
|
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 |
|
|
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. |
|
|
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).
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.
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:
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.
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",),
}
Source: Simple JWT: Getting started and Simple JWT: Settings
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.
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"}]}
Source: Simple JWT: Blacklist app and Simple JWT: Token types
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 |
|---|---|
|
Everyone (state it explicitly on public endpoints so the intent is visible). |
|
Authenticated users only. |
|
Users with |
|
Everyone for safe methods (GET, HEAD, OPTIONS); authenticated users for the rest. |
|
Maps POST/PUT/PATCH/DELETE to Django’s |
|
As above, plus read-only access for anonymous users. |
|
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.
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")
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 |
|---|---|
|
Client IP, for unauthenticated requests only; rate key |
|
User id (IP for anonymous users); rate key |
|
User id or IP, per |
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
LocMemCacheis per process, so with several Gunicorn workers each worker counts separately. Use Redis or Memcached in production (see Caching), or set a custom throttle’scacheattribute to another alias. -
Client IPs behind a proxy. Throttles use
X-Forwarded-Forwhen present, which a client can forge. SetNUM_PROXIESto the number of trusted proxies in front of the application so DRF picks the right address. -
Testing. Rates are copied into
SimpleRateThrottle.THROTTLE_RATESwhen DRF is imported, sooverride_settings(REST_FRAMEWORK=…)does not change them; patch the dictionary instead, and clear the cache between tests, as in this test from the suite:
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 |
|---|---|---|
|
|
Numbered pages and a total |
|
|
Clients want arbitrary windows (spreadsheets, exports). |
|
An opaque |
Large or fast-changing feeds: no |
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:
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 aFilterSetclass infilterset_classfor lookups such asgte/lte, renamed parameters and validation of the values; -
rest_framework.filters.SearchFilterfor?search=…acrosssearch_fields, where a prefix picks the lookup: none foricontains,^foristartswith,=foriexact,$foriregexand@for PostgreSQL full-text search. DRF 3.18 addedUnaccentedSearchFilter, which wraps the lookups in PostgreSQL’sunaccent(so "Jeremy" matches "Jérémy"); -
rest_framework.filters.OrderingFilterfor?ordering=priceor?ordering=-price, restricted toordering_fields, with the view’sorderingas the default.
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"].
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.
Source: django-filter: Migration guide
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 |
|---|---|---|
|
|
URL kwarg |
|
|
The same URLs, but the version comes from the URL namespace of an
|
|
|
Clean URLs; harder to try in a browser. |
|
|
Needs DNS and TLS per version. |
|
|
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.
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:
@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.
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:
-
CatalogStatsViewis a plainAPIViewwithout 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 thebook_by_isbnfunction view (@extend_schema(responses=BookSerializer), placed above@api_view). -
MyReviewsViewSet.get_queryset()usesrequest.user, which does not exist while the schema is generated; theswagger_fake_viewguard 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.
Source: drf-spectacular: @extend_schema and drf-spectacular FAQ: get_queryset at schema generation time
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.
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 |
|---|---|
|
A |
|
Django’s test case classes with |
|
Builds requests to call a view directly ( |
|
Test case with its own |
Source: Django REST framework: Testing
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.
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)
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"inREST_FRAMEWORK); the default is a multipart form, which cannot express nested data ornull. -
Assert on status and body. Check the exact status (
201, not "less than 400") and the data that proves the effect, astest_admin_creates_book_with_validationdoes with the normalised ISBN and the ignored read-onlystatus; 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-warnin 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.
Source: Django: Async views and adrf
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 |
|---|---|---|
|
|
Deprecated 3.8 |
|
|
Deprecated 3.9 |
|
|
django-filter 2.0 |
|
|
Removed DRF 3.7 |
CoreAPI schemas, |
OpenAPI 3 with drf-spectacular. |
Removed DRF 3.17 |
Built-in |
Still present but deprecated; drf-spectacular is the recommended replacement. |
DRF docs |
A |
The dictionary in Installing DRF and the running example: keys are strings, values are lists of dotted paths, every entry ends with a comma;
|
— |
A customer-facing |
Scope |
— |
An authentication token passed in the URL query string (Marani, ch. 8) |
Send credentials only in the |
— |
Error format for |
A dict keyed by item index ( |
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; |
3.16 |
2025-03-28 |
Django 5.1 and 5.2, Python 3.13; DRF views opted out of |
3.17 |
2026-03-18 |
Django 6.0 and Python 3.14; Python 3.9 dropped; CoreAPI support removed; |
3.18 |
2026-08-07 |
Django 6.1; Django 4.2, 5.0 and 5.1 dropped; errors of |
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"orexcludeon a serializer: new model columns become writable and visible. List fields. -
A
querysetattribute without user scoping on endpoints that return per-user data: object permissions do not filter lists. Overrideget_queryset(). -
get_serializer_class()/get_permissions()overrides that ignore@action(serializer_class=…, permission_classes=…). -
Custom actions that fetch objects with
Model.objects.get()instead ofself.get_object(), skipping queryset filtering and object permissions. -
SessionAuthenticationlisted first: anonymous clients get 403 withoutWWW-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
LocMemCachebehind several workers, or withoutNUM_PROXIESbehind 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 callfull_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 defhandlers on DRF views: not supported in DRF 3.18.