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.
# Create a virtual environment and install dependenciespython -m venv venv
source venv/bin/activate
pip install django djangorestframework django-filter
# Start the project and create an appdjango-admin startproject bookstore_api .
python manage.py startapp booksDRF 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',
'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, where nested relationships get resolved, and where 80% of the bugs in a DRF project originate. A bookstore API with authors and books. One author, many books.
from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator
classAuthor(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)
classMeta:
ordering = ['name']
def__str__(self):
return self.name
classBook(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)
classMeta:
ordering = ['-created_at']
def__str__(self):
returnf"{self.title} by {self.author.name}"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: the biggest DRF mistake I see in codebases 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
classAuthorSerializer(serializers.ModelSerializer):
book_count = serializers.IntegerField(
source='books.count', read_only=True
)
classMeta:
model = Author
fields = ['id', 'name', 'bio', 'email', 'book_count', 'created_at']
read_only_fields = ['created_at']
classBookSerializer(serializers.ModelSerializer):
author_name = serializers.CharField(
source='author.name', read_only=True
)
classMeta:
model = Book
fields = [
'id', 'title', 'author', 'author_name',
'isbn', 'genre', 'price', 'rating',
'published_date', 'is_available', 'created_at',
]
read_only_fields = ['created_at']
defvalidate_isbn(self, value):
"""Ensure ISBN is exactly 13 digits."""if not value.isdigit() orlen(value) != 13:
raise serializers.ValidationError(
"ISBN must be exactly 13 digits."
)
return value
defvalidate(self, data):
"""Cross-field validation."""if data.get('price') and data['price'] < 0:
raise serializers.ValidationError(
{"price": "Price cannot be negative."}
)
return data
classAuthorDetailSerializer(serializers.ModelSerializer):
"""Includes nested book data for author detail views."""
books = BookSerializer(many=True, read_only=True)
classMeta:
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
)
classAuthorViewSet(viewsets.ModelViewSet):
queryset = Author.objects.prefetch_related('books').all()
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
search_fields = ['name', 'bio']
ordering_fields = ['name', 'created_at']
defget_serializer_class(self):
if self.action == 'retrieve':
return AuthorDetailSerializer
return AuthorSerializer
@action(detail=True, methods=['get'])
deftop_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)
returnResponse(serializer.data)
classBookViewSet(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']
defperform_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)),
]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 -- add 'rest_framework.authtoken' to INSTALLED_APPS, run migrate, done. 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
classIsOwnerOrReadOnly(permissions.BasePermission):
"""
Custom permission to only allow owners of an object to edit it.
Assumes the model has an 'owner' field.
"""defhas_object_permission(self, request, view, obj):
# Read permissions are allowed for any requestif request.method in permissions.SAFE_METHODS:
return True# Write permissions only for the ownerreturn obj.owner == request.user
classIsAdminOrReadOnly(permissions.BasePermission):
"""
Allows full access to admin users and read-only for everyone else.
"""defhas_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return Truereturn request.user and request.user.is_staffRead 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. Seen this cause outages.
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
classBookAPITests(APITestCase):
defsetUp(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='[email protected]',
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',
)
deftest_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)
deftest_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)
deftest_create_book_unauthenticated(self):
"""Unauthenticated users cannot create books."""
data = {'title': 'Unauthorized Book'}
response = self.client.post('/api/v1/books/', data)
self.assertEqual(
response.status_code, status.HTTP_403_FORBIDDEN
)
deftest_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)
deftest_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
)
deftest_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)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.