Build a complete REST API with Django REST Framework. Covers serializers, viewsets, authentication, pagination, and filtering.
Modern applications are increasingly built around APIs — backends that serve data to JavaScript front-ends, mobile apps, and other services rather than rendering HTML pages. Django REST Framework is the standard tool for building these APIs in Django, providing everything you need to expose your data over HTTP cleanly and securely. This tutorial introduces building a REST API with Django REST Framework, covering the core concepts that turn your Django models into a working API.
Traditional Django renders HTML pages on the server and sends them to the browser, which works wonderfully for many sites. But modern applications often need to serve data rather than pages — to a JavaScript front-end that renders the interface itself, to a mobile app, or to other services that consume your data programmatically. An API exposes your data and operations over HTTP in a structured format, typically JSON, that any client can consume. Understanding that an API is about serving data to varied clients rather than rendering pages clarifies why you would build one and what Django REST Framework helps you do: turn your application's data and logic into a clean interface for those clients.
REST is an architectural style for APIs built around resources and standard HTTP methods. Resources — like users, products, or orders — are accessed at URLs, and the HTTP method indicates the operation: GET to retrieve, POST to create, PUT or PATCH to update, DELETE to remove. This convention makes APIs predictable, because the same patterns apply across different resources. A well-designed REST API feels consistent and intuitive to consume. Understanding REST as the practice of exposing resources at URLs and operating on them with standard HTTP methods gives you the mental model for designing an API that is clean and follows the conventions clients expect.
Django REST Framework builds on Django to provide everything needed for APIs: converting your data to and from JSON, handling the different HTTP methods, authentication and permissions, and much more, all integrating naturally with Django's models and views. Rather than building API functionality from scratch, you use its components, which capture the common patterns of API development. It is mature, widely used, and the default choice for APIs in the Django ecosystem. Understanding that the framework provides the building blocks — serialization, request handling, authentication — that you assemble into your API explains why it makes building one so much faster than doing it by hand.
The rest of this tutorial builds one small API end to end so each concept has concrete code attached: a catalog of books and authors in a project called myproject with an app called catalog. Install the framework into your virtual environment with pip install djangorestframework, then register it and set project-wide defaults in one place:
# myproject/settings.py
INSTALLED_APPS = [
# ... Django's built-in apps ...
"rest_framework",
"rest_framework.authtoken",
"catalog",
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
}
The REST_FRAMEWORK dictionary is the single source of defaults for every view in the project. Views can override them, but safe global defaults — authentication required for writes, pagination always on — means a view you forget to configure fails closed rather than open. The models are deliberately ordinary:
# catalog/models.py
from django.conf import settings
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=200)
def __str__(self):
return self.name
class Book(models.Model):
title = models.CharField(max_length=300)
isbn = models.CharField(max_length=13, unique=True)
author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name="books")
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
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 = ["-created_at"]
Note the default ordering: pagination over an unordered queryset produces inconsistent pages, so any model you expose through a paginated list should have a deterministic order.
At the heart of Django REST Framework are serializers, which convert between your Django models and JSON. A serializer takes a model instance and produces JSON to send to a client, and takes incoming JSON and validates and converts it back into model data. This two-way translation between your Python objects and the JSON format clients use is fundamental to an API. Serializers also handle validation of incoming data, ensuring it meets your requirements before it touches your models. Understanding serializers as the translators between your models and the JSON representation — and as the place validation happens — is central, because they are where most of the work of defining your API's data lives.
For the common case of serializing a Django model, the framework offers a serializer that generates much of the configuration automatically from your model. You specify the model and which fields to include, and it handles the translation based on the model's definition, much as Django's model forms do for forms. This saves substantial boilerplate, since most serializers map directly to models. You can still customize as needed, but the automatic generation handles the typical case cleanly. Understanding that you usually do not write serializers entirely by hand — that the framework builds most of one from your model — is what makes defining an API's data layer quick for the standard situations.
# catalog/serializers.py
from rest_framework import serializers
from .models import Author, Book
class AuthorSerializer(serializers.ModelSerializer):
class Meta:
model = Author
fields = ["id", "name"]
class BookSerializer(serializers.ModelSerializer):
owner = serializers.ReadOnlyField(source="owner.username")
author_name = serializers.CharField(source="author.name", read_only=True)
class Meta:
model = Book
fields = ["id", "title", "isbn", "author", "author_name",
"owner", "price", "published_on", "created_at"]
read_only_fields = ["created_at"]
def validate_isbn(self, value):
if not value.isdigit() or len(value) not in (10, 13):
raise serializers.ValidationError("ISBN must have 10 or 13 digits.")
return value
First, always list fields explicitly rather than using "__all__": an explicit list means a column added to the model later, such as an internal note, does not silently appear in public responses. Second, owner is read-only; the client must never be able to choose who owns a record, so the view sets it from the authenticated user instead (shown below). Third, field-level validate_<field> methods run after the field's own checks; cross-field rules belong in an object-level validate(self, attrs) method, which sees all the cleaned values together. Model fields declared unique=True, such as isbn, get a uniqueness validator automatically, so a duplicate ISBN returns a 400 with a clear message rather than an IntegrityError and a 500.
A common next step is embedding the full author object inside each book by declaring author = AuthorSerializer(read_only=True). It is convenient for clients, but nested serializers are read-only unless you implement create() and update() yourself, and every nested or source="author.name" field triggers a query per row unless the view's queryset uses select_related("author", "owner") for foreign keys or prefetch_related() for reverse and many-to-many relations. A page of 20 books that silently issues 40 extra queries is one of the most common DRF performance bugs, and it is invisible until the table grows.
Where serializers handle data, views handle requests — receiving them, using serializers to process data, and returning responses. The framework provides view classes that capture common API patterns, and ViewSets go further by bundling the standard operations for a resource — listing, creating, retrieving, updating, deleting — into a single class. This means a full set of endpoints for a resource can be defined very concisely. Understanding that views connect incoming requests to your serializers and models, and that ViewSets package the common operations together, shows how the framework lets you expose a resource's full API with minimal code while keeping the behavior consistent.
# catalog/views.py
from rest_framework import viewsets
from .models import Author, Book
from .permissions import IsOwnerOrReadOnly
from .serializers import AuthorSerializer, BookSerializer
class AuthorViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Author.objects.order_by("name")
serializer_class = AuthorSerializer
class BookViewSet(viewsets.ModelViewSet):
serializer_class = BookSerializer
permission_classes = [IsOwnerOrReadOnly]
def get_queryset(self):
return Book.objects.select_related("author", "owner")
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
Overriding get_queryset() rather than setting a class-level queryset is the idiomatic place to add select_related and to scope results per user. Note that a router derives its URL names from the queryset attribute, so when you only define get_queryset() you must pass basename when registering. perform_create() is the hook for injecting server-controlled values at save time. For operations that are not plain CRUD, the @action decorator adds extra routes to a ViewSet; inside one, fetch the object with self.get_object() so the same permission checks apply.
Connecting your ViewSets to URLs is handled by routers, which automatically generate the URL patterns for a resource's standard operations. You register a ViewSet with a router, and it creates the appropriate URLs for listing, creating, retrieving, and modifying that resource, following REST conventions. This removes the need to define each URL by hand and ensures consistency. Understanding that routers wire your ViewSets to a complete, conventional set of URLs automatically is what completes the picture of how little code it takes to expose a resource as a full REST API: define the serializer, the ViewSet, and register it with a router.
# myproject/urls.py
from django.urls import include, path
from rest_framework.authtoken.views import obtain_auth_token
from rest_framework.routers import DefaultRouter
from catalog.views import AuthorViewSet, BookViewSet
router = DefaultRouter()
router.register(r"books", BookViewSet, basename="book")
router.register(r"authors", AuthorViewSet)
urlpatterns = [
path("api/v1/", include(router.urls)),
path("api/v1/auth/token/", obtain_auth_token),
]
This produces /api/v1/books/ (list and create), /api/v1/books/{pk}/ (retrieve, update, partial update, destroy), and the same for authors. URL names follow the pattern book-list and book-detail, which you use with reverse() in tests and hyperlinked serializers. Putting a version prefix in the path from day one costs nothing and gives you a clean migration path when a breaking change eventually becomes unavoidable.
APIs need to control who can access what, and the framework provides authentication to identify the client and permissions to govern what they may do. Authentication might use tokens, sessions, or other schemes depending on your clients, and permissions let you require that a user be authenticated, or restrict actions to certain users. These are essential because an API often exposes data and operations that must be protected. Understanding that the framework gives you the tools to authenticate clients and enforce permissions — and that you must configure them appropriately for your API's security needs — is critical, because an API without proper access control exposes your data to anyone who finds it.
The global IsAuthenticatedOrReadOnly default stops anonymous writes, but it does nothing to stop one authenticated user editing another user's book. That needs an object-level permission:
# catalog/permissions.py
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return bool(request.user and request.user.is_authenticated)
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.owner_id == request.user.id
Two details catch people out. Setting permission_classes on a view replaces the global default rather than adding to it, which is why this class re-implements the authentication check in has_permission(). And has_object_permission() only runs when the view calls get_object() — it is not applied to list endpoints, so a list of private records must be restricted by filtering the queryset, never by object permissions alone. When a user may not see an object at all, filtering it out of the queryset yields a 404, which is preferable to a 403 that confirms the object exists.
A delightful feature of Django REST Framework is its browsable API: a web interface, generated automatically, that lets you explore and interact with your API in the browser during development. You can see the available endpoints, view responses, and even submit data through forms, all without a separate client. This makes developing and testing an API much easier, giving you immediate, visual feedback. Understanding that the framework provides this interactive interface for free during development is worth knowing because it speeds up building and debugging an API considerably, letting you verify your endpoints work as you build them rather than reaching for a separate tool.
Beyond the mechanics, a good API is well-designed: resources named clearly, consistent use of HTTP methods and status codes, sensible structure, helpful error responses, and pagination for large collections so a single request does not return enormous amounts of data. These design qualities make an API pleasant and predictable to consume, which matters because clients depend on your API's contract. Understanding that building an API is not just wiring up endpoints but designing a clean, consistent interface — and that the framework supports these good practices — is what separates an API that is a joy to work with from one that frustrates the developers who consume it.
A well-behaved API communicates outcomes through HTTP status codes — indicating success, that a resource was created, that input was invalid, that the client is not authorized, or that something was not found. Using the right status code for each situation lets clients understand and react to responses correctly, rather than guessing from the body. The framework helps you return appropriate codes. Understanding that status codes are part of an API's contract — a standard language for communicating what happened — is important for building an API that clients can consume reliably, because a client depends on these codes to handle success and the various kinds of failure appropriately in its own logic.
When an endpoint can return many items, sending them all in one response is slow and wasteful, so APIs paginate — returning results in pages with a way to request the next. The framework provides pagination that you configure, so large collections are served in manageable chunks rather than overwhelming the client and your server. Understanding that pagination is essential for any endpoint listing a potentially large set of items — and that it is a standard, expected feature of a good API — helps you build endpoints that perform well and behave predictably at scale, instead of one that tries to return thousands of records in a single enormous, slow response.
The framework ships three pagination styles, and the choice has real performance consequences:
| Class | Query parameters | Trade-offs |
|---|---|---|
PageNumberPagination | ?page=3 | Simple and familiar; runs a COUNT(*) on every request and deep pages get slower as the offset grows |
LimitOffsetPagination | ?limit=50&offset=100 | Client chooses the window; same count and deep-offset costs |
CursorPagination | opaque ?cursor= | Constant-time pages and stable results while rows are inserted; no total count and no jumping to an arbitrary page |
For feeds, activity logs and any table that grows into millions of rows, cursor pagination is usually the right answer: it requires an ordering on a unique, unchanging field (or a combination that is effectively unique), set as ordering = "-created_at" on your CursorPagination subclass, and it avoids both the expensive count and the duplicated or skipped items that offset paging produces when new rows arrive between requests. If you let clients choose the page size with page_size_query_param, always set max_page_size as well, or a single request can undo the protection pagination was meant to provide.
Clients often need not all of a resource's items but a subset matching some criteria, so APIs commonly support filtering and searching — letting a client request, say, only items of a certain category or matching a search term. The framework supports adding these capabilities to your endpoints in a structured, validated way. Understanding that providing filtering and search makes an API far more useful — letting clients get precisely the data they need rather than fetching everything and filtering themselves — is part of designing endpoints that serve real client needs efficiently, which is often the difference between an API that is practical to build on and one that forces clients into wasteful workarounds.
Like any code, an API should be tested, and the framework provides tools for writing tests that make requests to your endpoints and assert on the responses — checking that they return the right data, the right status codes, and enforce permissions correctly. Testing your API gives you confidence that it behaves as intended and that changes do not break existing behavior, which matters because clients depend on your API's contract. Understanding that testing endpoints is part of building a reliable API — verifying both the success cases and the handling of errors and permissions — is what lets you develop and evolve an API safely, catching regressions before they reach the clients that rely on it.
# catalog/tests.py
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from .models import Author, Book
class BookApiTests(APITestCase):
@classmethod
def setUpTestData(cls):
User = get_user_model()
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="Ada")
cls.book = Book.objects.create(title="A", isbn="9780000000001",
author=cls.author, owner=cls.alice,
price=Decimal("10.00"))
def test_create_sets_owner(self):
self.client.force_authenticate(self.bob)
payload = {"title": "B", "isbn": "9780000000002",
"author": self.author.pk, "price": "5.00"}
response = self.client.post(reverse("book-list"), payload, format="json")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["owner"], "bob")
def test_non_owner_cannot_update(self):
self.client.force_authenticate(self.bob)
url = reverse("book-detail", args=[self.book.pk])
response = self.client.patch(url, {"price": "1.00"}, format="json")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_list_query_count(self):
with self.assertNumQueries(2): # count + page
self.client.get(reverse("book-list"))
APITestCase gives you an APIClient as self.client, and force_authenticate() skips the authentication machinery so tests focus on permissions and behaviour. The last test is the cheapest insurance you can buy against the N+1 problem described earlier: if someone later adds a nested field without updating select_related, the query count jumps and the test fails.
Authentication tells you who a client is; throttling limits how often any client can call you, protecting the database from runaway scripts, credential-guessing against the token endpoint, and scraping. The built-in throttles are enabled in settings:
REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"] = [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
]
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
"anon": "60/minute",
"user": "1000/hour",
}
Throttle state lives in Django's cache, so two production details matter. The default local-memory cache is per process: with four Gunicorn workers each keeps its own counters, effectively multiplying the limit. Use a shared cache such as Redis for throttling to be meaningful. And anonymous clients are identified by IP address, which behind a reverse proxy is the proxy's address unless you configure NUM_PROXIES so the framework reads the correct entry from X-Forwarded-For. Give sensitive endpoints such as token issuance a stricter ScopedRateThrottle via throttle_scope.
Django REST Framework is the standard tool for building APIs in Django, turning your models into a clean HTTP interface that serves data to front-ends, mobile apps, and other services. An API serves structured data rather than rendered pages, following REST conventions where resources live at URLs and standard HTTP methods express operations. The framework's core is serializers, which translate between your models and JSON and validate incoming data, with ModelSerializers generating most of that translation automatically from your models. Views and ViewSets handle requests and bundle a resource's standard operations together, while routers wire them to conventional URLs with minimal code. Authentication and permissions control access, which is essential for protecting your data, and the browsable API makes development and testing easy. Beyond the mechanics, designing a clean, consistent, well-paginated interface is what makes an API pleasant to consume. With these pieces — serializers, ViewSets, routers, and proper access control — you can expose your Django application's data as a robust, secure REST API efficiently.