
Introduction
So you have got a Django project and you need an API. You could write raw views with JsonResponse, parse request bodies manually, build your own validation, roll your own authentication checking, and format error responses by hand. Or you could just install DRF and skip reimplementing half a framework.
The browsable API alone is worth the install. No more switching to Postman or curl during development — you test endpoints directly in the browser with a real UI. But the real value is serializers, which handle the hard parts: validation, nested writes, computed fields, and the translation between Django model instances and JSON. This tutorial builds a bookstore API from scratch. You should be comfortable with Python and have built at least one Django project before.
Project Setup and Configuration
Pin your dependencies from day one. Not later. Not "when we deploy." Now. The commands below pin Django 5.2 LTS (supported until April 2028) with DRF 3.18 and django-filter 26, which both support Django 5.2 through 6.1. Django 5.2 needs Python 3.10 or newer. Once things install, freeze the exact versions with pip freeze > requirements.txt.
# Create a virtual environment and install dependencies
python -m venv venv
source venv/bin/activate
pip install "django==5.2.*" "djangorestframework==3.18.*" "django-filter==26.*"
# Start the project and create an app
django-admin startproject bookstore_api .
python manage.py startapp booksDRF, its token app (rest_framework.authtoken, needed by TokenAuthentication), django-filter and the new app go in INSTALLED_APPS. Then global defaults in settings.py:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third-party
'rest_framework',
'rest_framework.authtoken',
'django_filters',
# Local
'books',
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.TokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
'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',
],
}Everything in that REST_FRAMEWORK dictionary applies as a default across all views. Token authentication, read-only public access, 20-item pages, filtering. Override per-view when needed.
Models and Serializers
Serializers are the core of DRF. Not views, not routers — serializers. They are where your data validation lives, where you control what gets exposed to the API consumer, and where nested relationships get resolved. When an API returns the wrong data or accepts bad input, the serializer is usually the first place to look. A bookstore API with authors and books. One author, many books.
from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator
class Author(models.Model):
name = models.CharField(max_length=200)
bio = models.TextField(blank=True)
email = models.EmailField(unique=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class Book(models.Model):
GENRE_CHOICES = [
('fiction', 'Fiction'),
('non_fiction', 'Non-Fiction'),
('science', 'Science'),
('technology', 'Technology'),
('history', 'History'),
]
title = models.CharField(max_length=300)
author = models.ForeignKey(Author, related_name='books', on_delete=models.CASCADE)
isbn = models.CharField(max_length=13, unique=True)
genre = models.CharField(max_length=20, choices=GENRE_CHOICES)
price = models.DecimalField(max_digits=6, decimal_places=2)
rating = models.FloatField(
validators=[MinValueValidator(0), MaxValueValidator(5)]
)
published_date = models.DateField()
is_available = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"{self.title} by {self.author.name}"Create and apply the migrations. This also creates the table for rest_framework.authtoken:
python manage.py makemigrations books
python manage.py migrate
People treat serializers as "model to JSON converters" and then wonder why their API has weird behavior. They are more like Django forms for your API. They validate incoming data, they control which fields are readable vs writable, they define how related objects appear in responses, and they run custom business logic during create and update. ModelSerializer inspects your model and auto-generates most of this, but the auto-generation is where people stop paying attention and start getting confused.
Here is a tangent worth making: a common DRF mistake is using fields = '__all__' on serializers. It exposes every model field to the API, including ones you did not intend to be public. And when someone adds a field to the model six months later, it silently becomes part of your API contract. Always list fields explicitly. Always.
Writing ModelSerializers and Nested Serializers
Three serializers here: one for authors in list views, one for books, and a detail serializer that nests full book data inside author responses.
from rest_framework import serializers
from .models import Author, Book
class AuthorSerializer(serializers.ModelSerializer):
book_count = serializers.IntegerField(
source='books.count', read_only=True
)
class Meta:
model = Author
fields = ['id', 'name', 'bio', 'email', 'book_count', 'created_at']
read_only_fields = ['created_at']
class BookSerializer(serializers.ModelSerializer):
author_name = serializers.CharField(
source='author.name', read_only=True
)
class Meta:
model = Book
fields = [
'id', 'title', 'author', 'author_name',
'isbn', 'genre', 'price', 'rating',
'published_date', 'is_available', 'created_at',
]
read_only_fields = ['created_at']
def validate_isbn(self, value):
"""Ensure ISBN is exactly 13 digits."""
if not value.isdigit() or len(value) != 13:
raise serializers.ValidationError(
"ISBN must be exactly 13 digits."
)
return value
def validate(self, data):
"""Cross-field validation."""
if data.get('price') and data['price'] < 0:
raise serializers.ValidationError(
{"price": "Price cannot be negative."}
)
return data
class AuthorDetailSerializer(serializers.ModelSerializer):
"""Includes nested book data for author detail views."""
books = BookSerializer(many=True, read_only=True)
class Meta:
model = Author
fields = ['id', 'name', 'bio', 'email', 'books', 'created_at']The source argument is doing the heavy lifting here. book_count uses source='books.count' to traverse the reverse ForeignKey relation and call .count() on it — all without adding anything to the model. author_name on BookSerializer uses source='author.name' to pull the author's name through the ForeignKey. Both are read-only. And both are the kind of thing you would otherwise solve with a SerializerMethodField and a custom method, which works but is slower and more verbose.
Validation happens at two levels. validate_isbn is field-level — it receives the raw value for that one field and can reject it. validate is object-level — it receives the entire validated data dictionary so you can compare fields against each other. The ISBN check ensures exactly 13 digits. The price check rejects negative values. But here is the thing that confuses people: DRF runs model validators too, from the Django model's validators list and the field constraints. So you get three layers of validation — field-level serializer, object-level serializer, and model validators. Knowing which layer catches what saves a lot of debugging time.
AuthorDetailSerializer nests the full book list inside author responses. Fewer round trips for the frontend. But watch performance on authors with hundreds of books — at that scale, you probably want a separate paginated endpoint.
Views and ViewSets
APIView for full control. GenericAPIView with mixins for partial CRUD. ModelViewSet for all of it in one class. Most of the time you want ModelViewSet.
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from .models import Author, Book
from .serializers import (
AuthorSerializer, AuthorDetailSerializer, BookSerializer
)
class AuthorViewSet(viewsets.ModelViewSet):
queryset = Author.objects.prefetch_related('books').all()
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
search_fields = ['name', 'bio']
ordering_fields = ['name', 'created_at']
def get_serializer_class(self):
if self.action == 'retrieve':
return AuthorDetailSerializer
return AuthorSerializer
@action(detail=True, methods=['get'])
def top_rated(self, request, pk=None):
"""Get an author's top-rated books."""
author = self.get_object()
top_books = author.books.filter(
rating__gte=4.0
).order_by('-rating')[:5]
serializer = BookSerializer(top_books, many=True)
return Response(serializer.data)
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.select_related('author').all()
serializer_class = BookSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
filterset_fields = ['genre', 'is_available', 'author']
search_fields = ['title', 'isbn', 'author__name']
ordering_fields = ['price', 'rating', 'published_date']
def perform_create(self, serializer):
"""Hook into the create process to add extra logic."""
serializer.save()
# You could send notifications, log events, etc.Read the code. The only thing worth calling out is the prefetch_related and select_related calls on the querysets. Without select_related('author') on the book viewset, Django fires a separate query per book to fetch the author name. A list of 50 books becomes 51 database queries. Add select_related, and it drops to 1. This is not optional. Install django-debug-toolbar during development and watch the query count on every endpoint you build.
URL Routing and Routers
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register('authors', views.AuthorViewSet)
router.register('books', views.BookViewSet)
urlpatterns = [
path('api/v1/', include(router.urls)),
]The project-level URLconf has to include the app's routes, otherwise none of the /api/v1/ URLs exist:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('books.urls')),
]
Three lines give you full CRUD routes for both resources plus the custom top_rated action. Version your URLs from the start. The v1/ prefix costs nothing now and saves real pain when you need breaking changes later.
Authentication and Permissions
Authentication: who is making the request. Permissions: what they are allowed to do. Separate concerns. Token auth is simplest — 'rest_framework.authtoken' is already in INSTALLED_APPS and the migrate step created its table, so you can issue tokens with python manage.py drf_create_token <username>. For SPAs and mobile apps, JWTs via djangorestframework-simplejwt are usually better. Stateless, no server-side storage.
Custom permissions are where it gets interesting. Or tedious, depending on your outlook.
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
"""
Custom permission to only allow owners of an object to edit it.
Assumes the model has an 'owner' field.
"""
def has_object_permission(self, request, view, obj):
# Read permissions are allowed for any request
if request.method in permissions.SAFE_METHODS:
return True
# Write permissions only for the owner
return obj.owner == request.user
class IsAdminOrReadOnly(permissions.BasePermission):
"""
Allows full access to admin users and read-only for everyone else.
"""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return request.user and request.user.is_staff
Read the code. has_permission runs on every request. has_object_permission runs only on detail views and receives the actual object. Add them to your viewset with permission_classes = [IsAuthenticated, IsOwnerOrReadOnly]. DRF checks in order; any failure denies the request.
Pagination, Filtering and Search
Without pagination, list endpoints return everything. Slow responses, memory problems, angry frontend developers. We already set defaults in settings.py. Three styles: PageNumberPagination for classic pages, LimitOffsetPagination for infinite scroll, CursorPagination for large datasets where counting total records is too expensive.
filterset_fields on BookViewSet means /api/v1/books/?genre=technology&is_available=true just works. No extra code. search_fields enables /api/v1/books/?search=django across title, ISBN, and author name. For full-text search or relevance scoring you would want Elasticsearch, but DRF's built-in search covers the common cases.
Always explicitly list allowed ordering_fields. Exposing arbitrary ordering can leak internal field names or enable expensive sorts on unindexed columns, which can get slow on large tables.
Testing Your API
A typo in a permission class can make every endpoint publicly writable. Writing permission tests takes minutes. Skipping them costs days when the bug hits production.
from django.contrib.auth.models import User
from rest_framework.test import APITestCase
from rest_framework import status
from decimal import Decimal
from .models import Author, Book
class BookAPITests(APITestCase):
def setUp(self):
"""Create test data that's shared across all tests."""
self.user = User.objects.create_user(
username='testuser', password='testpass123'
)
self.author = Author.objects.create(
name='Guido van Rossum',
email='guido@python.org',
bio='Creator of Python',
)
self.book = Book.objects.create(
title='Python Programming',
author=self.author,
isbn='9781234567890',
genre='technology',
price=Decimal('39.99'),
rating=4.5,
published_date='2025-01-15',
)
def test_list_books(self):
"""Anyone can list books without authentication."""
response = self.client.get('/api/v1/books/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 1)
def test_create_book_authenticated(self):
"""Authenticated users can create books."""
self.client.force_authenticate(user=self.user)
data = {
'title': 'Django for Professionals',
'author': self.author.id,
'isbn': '9780987654321',
'genre': 'technology',
'price': '49.99',
'rating': 4.8,
'published_date': '2025-06-01',
}
response = self.client.post('/api/v1/books/', data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Book.objects.count(), 2)
def test_create_book_unauthenticated(self):
"""Unauthenticated users get 401: TokenAuthentication is listed first."""
data = {'title': 'Unauthorized Book'}
response = self.client.post('/api/v1/books/', data)
self.assertEqual(
response.status_code, status.HTTP_401_UNAUTHORIZED
)
def test_filter_by_genre(self):
"""Books can be filtered by genre."""
response = self.client.get(
'/api/v1/books/?genre=technology'
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 1)
def test_search_books(self):
"""Books can be searched by title."""
response = self.client.get(
'/api/v1/books/?search=Python'
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(
len(response.data['results']) >= 1
)
def test_invalid_isbn_rejected(self):
"""Books with invalid ISBNs are rejected."""
self.client.force_authenticate(user=self.user)
data = {
'title': 'Bad ISBN Book',
'author': self.author.id,
'isbn': 'short',
'genre': 'fiction',
'price': '19.99',
'rating': 3.5,
'published_date': '2025-03-01',
}
response = self.client.post('/api/v1/books/', data)
self.assertEqual(
response.status_code, status.HTTP_400_BAD_REQUEST
)
self.assertIn('isbn', response.data)
Note the 401 in the unauthenticated test. DRF uses the first class in DEFAULT_AUTHENTICATION_CLASSES to decide the response type: TokenAuthentication sends a WWW-Authenticate header, so failed authentication returns 401. If SessionAuthentication came first, the same request would get 403.
Use force_authenticate instead of logging in through the API. Faster, keeps tests focused on behavior rather than auth flow. Test happy paths and error cases. Check status codes first, response body second.
For projects with more than a handful of tests, factory_boy saves real time. BookFactory.create(genre='fiction') beats writing setup code by hand. And run tests with python manage.py test books on CI. Every push. Tests that only run when someone remembers are not tests.
Is DRF overkill for a 3-endpoint API? Probably. But you will add endpoints 4 through 20 faster than you think, and by endpoint 6 you will be grateful for the serializers, the permission system, and the browsable API you did not have to build yourself.