🎯 Django REST Framework Basics
Django REST Framework (DRF) is the standard toolkit for building Web APIs on top of Django. Where Flask-RESTful hands you the parts, DRF hands you the assembled machine: serializers, generic views, viewsets, routers, auth, and a browsable API. This lesson takes you from a hand-written APIView to a full CRUD API in a handful of lines.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Install and configure DRF, and explain how it extends Django
- Write serializers (including
ModelSerializer) to convert models to/from JSON - Model relationships with nested and related serializer fields
- Choose between APIView, generic views, and viewsets and wire them with routers
- Apply authentication, permissions, filtering, and pagination
Estimated Time: 50–70 minutes • Difficulty: Intermediate
Hands-on: Turn a Django Book model into a full CRUD API with a viewset and a router.
In This Lesson
What DRF Adds to Django
Django already gives you an ORM, an admin, routing, and templates. But Django's built-in views render HTML — they aren't built for JSON APIs. Django REST Framework layers API-specific machinery on top while keeping Django's rapid-development philosophy.
Real-world analogy: if Django is a fully furnished house, DRF is a professional home-office fit-out — purpose-built for one job (serving APIs) yet matching the existing architecture so nothing feels bolted on.
Installation and setup
pip install djangorestframework
# settings.py
INSTALLED_APPS = [
# ...
"rest_framework",
]
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": 10,
}
Serializers: Translating Data
A serializer converts complex objects (like Django model instances) into native Python types that render to JSON — and validates and converts incoming JSON back into objects. It is the two-way translator between your database and the outside world.
Real-world analogy: a serializer is an interpreter at a summit. When your API replies, it translates "Django-speak" into "JSON-speak" the client understands; when the client sends data, it translates back — and refuses anything that doesn't make sense.
📖 Serialize vs Deserialize
Serialize: object → JSON (reading data out).
Deserialize: JSON → validated object (writing data in). Deserializing always goes through .is_valid() before .save().
The model
# models.py
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=100)
author = models.CharField(max_length=100)
published_date = models.DateField()
isbn = models.CharField(max_length=13)
def __str__(self):
return self.title
ModelSerializer — the workhorse
You could write a plain Serializer and declare every field by hand, but ModelSerializer reads the model and generates the fields, validation, and default create()/update() for you.
# serializers.py
from rest_framework import serializers
from .models import Book
class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = ["id", "title", "author", "published_date", "isbn"]
# fields = "__all__" # or grab everything
# read_only_fields = ["id"] # id is read-only by default
Using a serializer
# Serialize one object
book = Book.objects.get(id=1)
BookSerializer(book).data
# {'id': 1, 'title': 'Django for Beginners', ...}
# Serialize many
BookSerializer(Book.objects.all(), many=True).data
# Deserialize (create) — validate, then save
data = {"title": "Python Crash Course", "author": "Eric Matthes",
"published_date": "2019-05-03", "isbn": "9781593279288"}
serializer = BookSerializer(data=data)
if serializer.is_valid():
book = serializer.save()
else:
print(serializer.errors)
Validation
import datetime
from rest_framework import serializers
class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = "__all__"
def validate_isbn(self, value): # field-level
digits = value.replace("-", "")
if not (digits.isdigit() and len(digits) == 13):
raise serializers.ValidationError("ISBN must be 13 digits")
return value
def validate(self, data): # object-level
if data["published_date"] > datetime.date.today():
raise serializers.ValidationError("published_date cannot be in the future")
return data
Serializer Relationships
Real data has relationships. Suppose a Book now points to an Author via a foreign key. DRF gives you several ways to represent that link, from a bare ID to a fully nested object.
# models.py
class Author(models.Model):
name = models.CharField(max_length=100)
biography = models.TextField(blank=True)
class Book(models.Model):
title = models.CharField(max_length=100)
author = models.ForeignKey(Author, on_delete=models.CASCADE,
related_name="books")
published_date = models.DateField()
isbn = models.CharField(max_length=13)
| Field type | Represents the author as… | Good for |
|---|---|---|
PrimaryKeyRelatedField | the ID (e.g. 3) | writable links, compact payloads |
StringRelatedField | __str__ (e.g. "Harper Lee") | read-only display |
SlugRelatedField | a chosen field (e.g. name) | human-friendly writable links |
HyperlinkedRelatedField | a URL to the author | discoverable, RESTful APIs |
| Nested serializer | the full author object | read-heavy detail views |
The read-vs-write pattern
A common professional pattern: show a nested object when reading, but accept a plain ID when writing. Use two fields — one read-only nested, one write-only ID — both mapped to author via source.
class AuthorSerializer(serializers.ModelSerializer):
class Meta:
model = Author
fields = ["id", "name", "biography"]
class BookSerializer(serializers.ModelSerializer):
author = AuthorSerializer(read_only=True) # nested on read
author_id = serializers.PrimaryKeyRelatedField( # id on write
queryset=Author.objects.all(), source="author", write_only=True
)
class Meta:
model = Book
fields = ["id", "title", "author", "author_id", "published_date", "isbn"]
💡 Reverse relationships
Because Book.author uses related_name="books", an AuthorSerializer can include every book by that author with books = BookSerializer(many=True, read_only=True).
Views: Three Levels of Abstraction
DRF lets you handle requests at whatever level of control you need. The class hierarchy runs from the flexible-but-verbose APIView up to the terse generic views. Choose based on how much custom logic a view needs.
Level 1 — APIView (full control)
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.http import Http404
from .models import Book
from .serializers import BookSerializer
class BookListAPIView(APIView):
def get(self, request):
serializer = BookSerializer(Book.objects.all(), many=True)
return Response(serializer.data)
def post(self, request):
serializer = BookSerializer(data=request.data)
serializer.is_valid(raise_exception=True) # auto 400 on failure
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
Level 2 — Generic views (common patterns)
Most endpoints are variations of list/create and retrieve/update/delete. Generic views implement those; you just supply a queryset and a serializer_class.
from rest_framework import generics
class BookList(generics.ListCreateAPIView):
queryset = Book.objects.all()
serializer_class = BookSerializer
class BookDetail(generics.RetrieveUpdateDestroyAPIView):
queryset = Book.objects.all()
serializer_class = BookSerializer
| Generic view | HTTP methods | Use |
|---|---|---|
ListCreateAPIView | GET, POST | a collection you read and add to |
RetrieveUpdateDestroyAPIView | GET, PUT, PATCH, DELETE | a single item, full lifecycle |
ListAPIView | GET | read-only collection |
RetrieveAPIView | GET | read-only single item |
Override hooks like get_queryset() and perform_create() when you need custom behavior without giving up the generics:
class BookList(generics.ListCreateAPIView):
serializer_class = BookSerializer
def get_queryset(self):
qs = Book.objects.all()
title = self.request.query_params.get("title")
return qs.filter(title__icontains=title) if title else qs
def perform_create(self, serializer):
serializer.save(created_by=self.request.user) # attach the user
ViewSets & Routers
A ViewSet bundles the logic for a whole resource — list, create, retrieve, update, partial-update, destroy — into one class. A Router then generates all the URL patterns automatically.
Real-world analogy: if generic views are individual light switches, a ModelViewSet is the smart panel that controls the whole room, and the router is the wiring that connects it correctly without you thinking about each cable.
The whole CRUD API in a few lines
# views.py
from rest_framework import viewsets
from .models import Book
from .serializers import BookSerializer
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import BookViewSet
router = DefaultRouter()
router.register(r"books", BookViewSet)
urlpatterns = [
path("api/", include(router.urls)),
]
That router creates GET/POST /api/books/ and GET/PUT/PATCH/DELETE /api/books/<pk>/, plus a browsable API root at /api/. One viewset replaced two generic views and all the URL wiring.
Custom actions
For operations that don't fit plain CRUD, add an @action:
from rest_framework.decorators import action
from rest_framework.response import Response
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
@action(detail=False, methods=["get"])
def recent(self, request):
# GET /api/books/recent/
recent = Book.objects.order_by("-published_date")[:5]
return Response(self.get_serializer(recent, many=True).data)
@action(detail=True, methods=["post"])
def mark_as_read(self, request, pk=None):
# POST /api/books/<pk>/mark_as_read/
book = self.get_object()
book.readers.add(request.user)
return Response({"status": "marked as read"})
Authentication & Permissions
DRF separates two questions cleanly: authentication asks "who are you?" and permissions ask "are you allowed to do this?" You configure them globally in settings and override per-view when needed.
Token authentication
# settings.py
INSTALLED_APPS = [
# ...
"rest_framework.authtoken",
]
# then: python manage.py migrate
# urls.py
from rest_framework.authtoken.views import obtain_auth_token
urlpatterns += [path("api/token/", obtain_auth_token, name="api_token")]
Clients POST credentials to /api/token/, receive a token, and send it on every request:
curl -X POST http://localhost:8000/api/token/ \
-d "username=user1&password=secret"
# -> {"token": "9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"}
curl http://localhost:8000/api/books/ \
-H "Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"
Built-in permission classes
AllowAny— no restriction.IsAuthenticated— must be logged in.IsAdminUser— only staff.IsAuthenticatedOrReadOnly— anyone can read; only authenticated users can write.
Custom object-level permission
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
"""Anyone may read; only the owner may modify."""
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS: # GET, HEAD, OPTIONS
return True
return obj.created_by == request.user
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
⚠️ Choosing a token scheme
DRF's built-in TokenAuthentication issues simple, non-expiring tokens stored in the database. For production apps that need expiry and refresh, most teams use djangorestframework-simplejwt — covered in the next lesson when we connect a React frontend.
Filtering & Pagination
Collections grow. DRF integrates with django-filter for query-parameter filtering, adds search and ordering backends, and paginates automatically once you set a page size.
pip install django-filter
# settings.py
INSTALLED_APPS += ["django_filters"]
REST_FRAMEWORK["DEFAULT_FILTER_BACKENDS"] = [
"django_filters.rest_framework.DjangoFilterBackend",
]
# views.py
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, viewsets
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ["author", "published_date__year"] # exact match
search_fields = ["title", "author__name"] # ?search=
ordering_fields = ["title", "published_date"] # ?ordering=
That single view now supports:
?author=3— books by author ID 3?published_date__year=2020— books from 2020?search=django— title or author name contains "django"?ordering=-published_date— newest first
Custom pagination
from rest_framework.pagination import PageNumberPagination
class StandardPagination(PageNumberPagination):
page_size = 20
page_size_query_param = "page_size" # let clients override, within reason
max_page_size = 100
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
pagination_class = StandardPagination
💡 The browsable API
Because DRF ships a BrowsableAPIRenderer, visiting any endpoint in a browser gives you a human-friendly HTML page with forms to test POST/PUT/PATCH, login controls, and links to related resources — a free interactive playground while you develop.
Hands-on Exercise
🏋️ Build a Product Catalog API
Objective: Go from model to full CRUD API using a serializer, a viewset, and a router.
Instructions:
- Create a
Productmodel:name,price(Decimal),in_stock(Boolean),created_at(auto). - Write a
ProductSerializer(ModelSerializer) withcreated_atread-only. - Add a validator that rejects a negative
price. - Create a
ProductViewSet(ModelViewSet) with search onnameand ordering onprice. - Register it on a
DefaultRouterat/api/products/and test it in the browsable API.
💡 Hint
Field-level validation lives in a method named validate_<fieldname> on the serializer. For search and ordering, set filter_backends, search_fields, and ordering_fields on the viewset — no extra URLs required.
✅ Solution
# models.py
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=120)
price = models.DecimalField(max_digits=8, decimal_places=2)
in_stock = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
# serializers.py
from rest_framework import serializers
from .models import Product
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ["id", "name", "price", "in_stock", "created_at"]
read_only_fields = ["created_at"]
def validate_price(self, value):
if value < 0:
raise serializers.ValidationError("price cannot be negative")
return value
# views.py
from rest_framework import viewsets, filters
from .models import Product
from .serializers import ProductSerializer
class ProductViewSet(viewsets.ModelViewSet):
queryset = Product.objects.all()
serializer_class = ProductSerializer
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["name"]
ordering_fields = ["price", "created_at"]
# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ProductViewSet
router = DefaultRouter()
router.register(r"products", ProductViewSet)
urlpatterns = [path("api/", include(router.urls))]
🎯 Quick Quiz
Question 1: What is the primary job of a DRF serializer?
Question 2: Which combination gives you a full CRUD API with the least code?
Question 3: Which permission class lets anyone read but only authenticated users write?
Best Practices
✅ Do
- Reach for
ModelSerializerand generic views/viewsets before writing custom code. - Set a default pagination class so no endpoint returns an unbounded list.
- Use
serializer.is_valid(raise_exception=True)for clean automatic 400s. - Set sensible global
DEFAULT_PERMISSION_CLASSES; loosen per-view, don't tighten from open. - Use
select_related/prefetch_relatedin querysets to avoid N+1 queries with nested serializers.
⚠️ Don't
- Don't leave the default permission as
AllowAnyin production by accident. - Don't nest serializers deeply for writes — it gets painful fast; use write-only ID fields.
- Don't expose sensitive model fields; list
fieldsexplicitly rather than"__all__"on user data. - Don't forget to run
migrateafter addingauthtoken.
Summary & Quiz
🎉 Key Takeaways
- Serializers translate models ⇄ JSON and validate input;
ModelSerializerderives fields from the model. - Relationships can be shown as IDs, strings, slugs, hyperlinks, or nested objects — the read-nested/write-ID pattern is a reliable default.
- Views come in three tiers: APIView (control), generic views (patterns), and viewsets + routers (least code).
- Authentication answers "who", permissions answer "allowed?"; filtering, search, ordering, and pagination are configuration, not code.
📚 Further Reading
🚀 What's Next?
You now have a secure, browsable API. The final step is putting it to work: next we connect a Python backend to a React frontend — CORS, token flow, Axios clients, and state management with React Query.
🎉 Nice work!
You can build a full DRF API from a model. Time to wire it up to a real frontend.