Django Backend Foundations: Extending with Django REST Framework


You've set up your project, defined your models, and taken full advantage of Django’s powerful built-in features. Now it’s time to take things one step further — into the world of APIs.
Django REST Framework (DRF) is the toolkit that turns Django into an API powerhouse. While Django itself is more geared toward rendering HTML via templates, DRF extends that foundation to serve structured data — typically JSON — to frontend apps, mobile clients, or other services.
In other words:
Django is great at building websites. DRF makes it great at building APIs.
Add DRF to the mix:
pip install djangorestframework
And add it to INSTALLED_APPS
in settings.py
:
INSTALLED_APPS = [
...,
'rest_framework',
]
Now, DRF doesn’t replace Django. It extends it — adding tools that make it easy to build and manage APIs. Without it, you'd be hand-coding JSON responses and parsing incoming payloads. DRF gives you structure, validation, and control.
While Django by default is more HTML/template-oriented, DRF gives you:
API views
Serializers (for transforming models/data to JSON)
Browsable API interface (a huge dev UX win)
Authentication, permissions, throttling
Pagination, filtering, etc.
DRF turns Django into a powerful backend for modern web or mobile apps that consume JSON over HTTP.
It provides:
Serializers:
which are the heart of DRF. They do two major things:
Serialization (Outgoing Data): convert complex data (like model instances e.g. the
Profile
model) into native Python datatypes (like dictionaries, lists, strings) that can then be easily rendered into data formats such as JSON or XML, suitable for API responses (outgoing data).Deserialization & Validation (Incoming Data): take incoming data from the client (e.g., JSON received in an HTTP POST request), parse it into native Python data types, thoroughly validating it against a defined schema, and then converting it into complex Python data types (like Django model instances) for database operations.
A Basic Example
Let’s work with a basic example. Do the following:
create a
library
appregister it in
settings.py
Then in library/models.py
:
# library/models.py
from django.db import models
# Create your models here.
class Book(models.Model):
title = models.CharField(max_length=100)
author = models.CharField(max_length=100)
published = models.DateField()
Finally, create a serializers.py
file
Model to JSON: ModelSerializer
DRF provides a ModelSerializer
that auto-generates serializer fields based on the model.
from rest_framework import serializers
from .models import Book
class BookSerializer(serializers.ModelSerializer):
# to configure behavior
class Meta:
model = Book
fields = ['id', 'title', 'author', 'published']
# read_only_fields = ['id'] # optionally make fields read-only; will not be accepted during create(), update()
# extra_kwargs = {'bio': {'min_length': 5}} # add extra validation/options
# exclude = ['bio'] # fields from the model to be excluded
So you don’t need to define each field manually like this:
title = serializers.CharField(max_length=100)
author = serializers.CharField(max_length=100)
published = serializers.DateField()
That’s what ModelSerializer
saves you from doing. It is the most commonly used serializer. It provides a convenient shortcut for creating serializers that directly map to Django models.
Practice in the shell:
python manage.py shell
from library.models import Book
from library.serializers import BookSerializer
book = Book.objects.create(title="Animal Farm", author="George Orwell", published="1945-08-17")
serializer = BookSerializer(book)
print(serializer.data)
Without the serializer, you get a complex “unreadable” model instance:
With the serializer, you get your data in a readable, python datatype; a dictionary.
This is ready to become JSON in your API response.
From JSON to Model: Deserialization & Validation
You can also take incoming JSON, validate it, and turn it into a model instance.
Practice in the shell:
data = {'title': 'Brave New World', 'author': 'Aldous Huxley', 'published': '1932-01-01'}
serializer = BookSerializer(data=data)
if serializer.is_valid():
book = serializer.save()
print(book)
else:
print(serializer.errors)
And that’s it! You get your model instance back.
Shift+Enter
to move on to the next line in your terminal without running the command. Remember your indentation blocks, as well.DRF:
Validates the data based on your model field types
Saves it if valid (Peep the
QuerySet
at the bottom with 2 items)Returns errors if something is wrong (e.g., missing fields or bad formats)
What if you wanted to have your own custom validation? Here’s how:
class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = ['id', 'title', 'author', 'published']
# validation function
def validate_title(self, value):
if "bad" in value.lower():
raise serializers.ValidationError("This book title is not allowed.")
return value
If you get True
when checking if the instance is valid, restart your shell.
You may be wondering where I came up with the validate_title
function. I didn’t. It’s a convention. To add validation for a field, you initialize the validate function using this format: validate_[fieldname]
and when you run is_valid()
, DRF automatically detects and calls it during validation of that specific field. That means you can also run validate_author()
and validate_published()
.
For cross-field validation or when your validation depends on multiple values:
def validate(self, attrs):
if attrs['author'] == attrs['title']:
raise serializers.ValidationError("Author and title can't be the same.")
return attrs
If you try to save without validating:
data = {"title": "bad", "author": "Aldous Huxley", "published": "1932-01-01"}
serializer = BookSerializer(data=data)
serializer.save()
Traceback (most recent call last):
File "<console>", line 1, in <module>
File "C:\Python312\Lib\site-packages\rest_framework\serializers.py", line 178, in save
assert hasattr(self, '_errors'), (
^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: You must call `.is_valid()` before calling `.save()`.
So, when you call is_valid()
, DRF runs:
Built-in field validation (e.g.,
required=True
,max_length
)Your custom
validate_<fieldname>
methodsAny
validators
passed directly to the fieldAnd finally, the
validate(self, attrs)
method (for cross-field validation)
Note that you can also attach one or more custom validator functions like this:
def no_numbers(value):
if any(char.isdigit() for char in value):
raise serializers.ValidationError("Numbers are not allowed.")
author = serializers.CharField(validators=[no_numbers])
Or create reusable validator classes like this:
from rest_framework import serializers
class MustStartWithCapital:
def __call__(self, value):
if not value[0].isupper():
raise serializers.ValidationError("Must start with a capital letter.")
title = serializers.CharField(validators=[MustStartWithCapital()])
serializers.ModelSerializer
is a convenient shortcut when your data maps to a Django model, but there are many cases where you’re not working with models at all. That's where the base class serializers.Serializer
comes in.
serializers.Serializer
— The Base Class
You use this when:
You’re not working with a Django model.
You want manual control over fields and validation.
You’re accepting or sending arbitrary JSON data — like for third-party APIs, config files, or simple data transforms.
A basic example:
Let’s build a serializer to accept a basic contact form:
from rest_framework import serializers
class ContactSerializer(serializers.Serializer):
name = serializers.CharField(max_length=100)
email = serializers.EmailField()
message = serializers.CharField()
def validate_name(self, value):
if not value[0].isupper():
raise serializers.ValidationError("Name must start with a capital letter.")
return value
def validate(self, data):
message = data.get("message", None)
if message is not None and "help" not in message.lower():
raise serializers.ValidationError("Message must include the word 'help'.")
return data
This:
Defines fields manually (See list of field types in Part 2)
Supports custom per-field validation
Allows cross-field validation with
validate(self, data)
How we use this
# library/views.py
def contact_form:
data = {
"name": "Alice",
"email": "alice@example.com",
"message": "I need help with your service"
}
serializer = ContactSerializer(data=data)
if serializer.is_valid():
print(serializer.validated_data)
else:
print(serializer.errors)
Notice:
There's no
.save()
unless you implement it yourself.You’re in full control over what “saving” means — could be sending an email, writing to a log, or making an API call.
If you want to mimic ModelSerializer
behavior, you can define your own methods:
# serializers.py
def create(self, validated_data):
# e.g create a user object and immediately link it to a profile
user = User.objects.create(**validated_data)
Profile.objects.create(user=user)
return user
def update(self, instance, validated_data):
if 'status' in validated_data and validated_data['status'] == 'archived':
instance.archived_at = timezone.now()
# other business logic like logging
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
But DRF won’t call them unless you invoke .save()
— just like with ModelSerializer
(which has it’s create()
and update()
methods generated automatically)
Now if we want to save:
# library/serializers.py
class ContactSerializer(serializers.Serializer):
# ...
# commented out the second validate function btw; add help to your message if you don't want to
def create(self, validated_data):
# In a real app, you might send this data to email or save in a model
print("Contact submission received!")
print(f"From: {validated_data['name']} <{validated_data['email']}>")
print(f"Message: {validated_data['message']}")
return validated_data # no DB, so we just return the data
# in shell
data = {
"name": "tito", # will trigger an error; write it better for smooth sailing
"email": "tito@example.com",
"message": "Hey, can we chat?"
}
serializer = ContactSerializer(data=data)
if serializer.is_valid():
serializer.save()
else:
print(serializer.errors)
You should get:
Create multiple objects:
data = [
{"id": 1, "title": "Updated Book 1", "author": "New Author 1", "published": "2000-01-01"},
{"id": 2, "title": "Updated Book 2", "author": "New Author 2", "published": "2010-02-02"},
]
serializer = BookSerializer(data=data, many=True)
serializer.is_valid()
serializer.save()
How .save()
decides between create()
and update()
DRF checks whether the serializer was initialized with an instance or not:
# create a new object
serializer = BookSerializer(data=data) # no instance passed
serializer.is_valid()
serializer.save() # calls create()
# update an existing object (partial=True; to pass only some of the model's fields)
serializer = BookSerializer(instance=book, data=data, partial=True) # an instance is an already existing object
serializer.is_valid()
serializer.save() # calls update()
To update:
# can't actually persist for non-model serializers
def update(self, instance, validated_data):
for attr, value in validated_data.items():
setattr(instance, attr, value)
return instance
Nested Serializers
Let’s say a Profile
belongs to a User
.
# models.py
from django.contrib.auth.models import User
class Profile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE)
bio = models.TextField(blank=True)
Now, to include the full user data in the profile API, not just the ID:
# serializers.py
from django.contrib.auth.models import User
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email']
class ProfileSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
class Meta:
model = Profile
fields = ['id', 'user', 'bio']
In views, this would return:
{
"id": 1,
"user": {
"id": 5,
"username": "tito",
"email": "tito@example.com"
},
"bio": "Backend developer"
}
If you want to create or update the related user from this serializer, you’ll need to override create()
or update()
.
Example:
class ProfileSerializer(serializers.ModelSerializer):
user = UserSerializer()
class Meta:
model = Profile
fields = ['id', 'user', 'bio']
def create(self, validated_data):
user_data = validated_data.pop('user')
user = User.objects.create(**user_data)
profile = Profile.objects.create(user=user, **validated_data)
return profile
Practice:
# in shell
data = {
"user": {"username": "jane", "email": "jane@example.com"},
"bio": "Fullstack dev"
}
serializer = ProfileSerializer(data=data)
if serializer.is_valid():
profile = serializer.save()
print(profile)
Browsable API interface:
The Browsable API is a web-based interface that DRF provides when you hit your API endpoints via a browser (instead of something like Postman or curl
). It’s automatically generated and interactive.
Example:
When you visit http://localhost:8000/api/books/
, you might see a clean form to:
Submit POST data via a web form
Browse paginated GET results
See validation errors in real time
Test your endpoints without writing frontends
The Browsable API is rendered using:
DRF’s
BrowsableAPIRenderer
Which is enabled by default in your
DEFAULT_RENDERER_CLASSES
setting
# settings.py
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer',
]
}
If you only want JSON responses (e.g., in production), you can remove 'BrowsableAPIRenderer'
— but it’s very helpful in dev.
Let’s try it out
- Create a basic view:
from rest_framework import generics
from .models import Book
from .serializers import BookSerializer
class BookListCreateView(generics.ListCreateAPIView):
queryset = Book.objects.all()
serializer_class = BookSerializer
- Add to
urls.py
:
# library/urls.py
from django.urls import path
from .views import BookListCreateView
urlpatterns = [
path('books/', BookListCreateView.as_view()),
]
# root urls.py, add this
path('library/', include('library.urls')),
- Visit
http://localhost:8000/library/books/
in your browser and there it is, a list of our books 🎉
At the bottom, you can make POST requests either via the form or using a JSON object:
Response:
DRF Views
Now that you've seen serializers in action and DRF’s browsable UI, it’s time to move onto the part of the stack that actually handles HTTP requests — views.
In Django, views are the bridge between the request and the response. They define how data is processed, what gets returned, and who is allowed to access it.
Django REST Framework (DRF) builds on this idea and provides powerful, flexible ways to write views for APIs.
Let’s walk through the main types of views DRF provides — and when to use each.
1. Function-Based Views (FBVs)
DRF gives you decorators like @api_view
to convert plain Django functions into API views.
from rest_framework.decorators import api_view
from rest_framework.response import Response
@api_view(['GET'])
def hello_world(request):
return Response({"message": "Hello, world!"})
Try it out: GET /hello/
Response: {"message": "Hello, world!"}
This is great for simple, one-off endpoints, quick prototypes, or when you're just starting out.
2. Class-Based Views (CBVs)
These are based on Django’s own class-based views but add extra methods for handling APIs.
a. APIView (The Base Class)
Think of APIView
as the lowest-level building block for API endpoints. It is great for full control over request handling and custom behavior, especially when you need logic that doesn’t map neatly to CRUD.
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
class HelloWorldView(APIView):
def get(self, request):
return Response({"message": "Hello, world!"})
def post(self, request):
return Response({"you_sent": request.data}, status=status.HTTP_201_CREATED)
b. ViewSet
(base class — no built-in methods)
This is barebones. You must explicitly define list()
, retrieve()
, etc. You should this when you want complete control.
from rest_framework.viewsets import ViewSet
from rest_framework.response import Response
from .models import Book
from .serializers import BookSerializer
class BookViewSet(ViewSet):
def list(self, request):
books = Book.objects.all()
serializer = BookSerializer(books, many=True)
return Response(serializer.data)
def retrieve(self, request, pk=None):
book = Book.objects.get(pk=pk)
serializer = BookSerializer(book)
return Response(serializer.data)
3.Generic Views
If you're working with models and serializers, this is where DRF really shines.
Generic views abstract away most of the boilerplate, so you don’t need to keep writing .get()
, .post()
, .put()
, etc.
Here’s a classic example:
from rest_framework.generics import ListCreateAPIView
from .models import Book
from .serializers import BookSerializer
class BookListCreateView(ListCreateAPIView):
queryset = Book.objects.all()
serializer_class = BookSerializer
That’s it — this one class now:
Lists all books when you
GET
Allows creation of a new book on
POST
You can also do RetrieveAPIView
, UpdateAPIView
, DestroyAPIView
, or combine them like RetrieveUpdateDestroyAPIView
.
Still using our Book
model:
# library/views.py
from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView
from .models import Book
from .serializers import BookSerializer
# GET + POST requests (List + Create)
class BookListCreateView(ListCreateAPIView):
queryset = Book.objects.all()
serializer_class = BookSerializer
# PATCH + DELETE
class BookDetailView(RetrieveUpdateDestroyAPIView):
queryset = Book.objects.all()
serializer_class = BookSerializer
# library/urls.py
from django.urls import path
from .views import BookListCreateView, BookDetailView
urlpatterns = [
path('books/', BookListCreateView.as_view()),
path('books/<int:pk>/', BookDetailView.as_view()),
]
Try it out:
GET /books/
POST /books/
→ create a bookGET /books/1/
→ retrieve book with ID 1PATCH /books/1/
→ partial updateDELETE /books/1/
→ delete (Click on red DELETE button at the top-right corner)
You can also add mixins to your GenericViewSet. Use this if you want to build a custom combination of CRUD operations.
from rest_framework.viewsets import GenericViewSet
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
from .models import Book
from .serializers import BookSerializer
class BookViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
Use this when you only want specific operations (e.g., list + retrieve, but not create/update/delete).
4. ViewSets (For Even More Abstraction)
ViewSets are the highest-level abstraction DRF offers. Instead of writing separate views for list, create, update, retrieve, and delete — you define a single class, and DRF wires up the logic automatically.
# library/views.py
from rest_framework import viewsets
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
By default, ModelViewSet
gives you:
GET /books/
(list)GET /books/1/
(retrieve)POST /books/
(create)PUT /books/1/
(update)PATCH /books/1/
(partial update)DELETE /books/1/
(delete)
This is great for fully RESTful resources with minimal boilerplate.
You then use DRF’s routers to wire it up:
# urls.py
from rest_framework.routers import DefaultRouter
from .views import BookViewSet
router = DefaultRouter()
router.register(r'books', BookViewSet)
urlpatterns = router.urls
When using ViewSets, you don’t define your own urlpatterns
manually. Instead, DRF provides a router that auto-generates all the routes.
When to Use What?
Use Case | Recommended View Type |
One-off endpoint, simple logic | Function-Based View |
Full control over logic | APIView |
CRUD with models/serializers | Generic Views |
Full REST resource with routing | ViewSets + Routers |
Custom actions
You may also add custom methods with the @action
decorator.
from rest_framework.decorators import action
from rest_framework.response import Response
class BookViewSet(ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
@action(detail=True, methods=['post'])
def publish(self, request, pk=None):
book = self.get_object()
book.published = True
book.save()
return Response({'status': 'book published'})
This creates /books/{pk}/publish/
(POST).
Permissions
Permissions in DRF control who is allowed to perform actions (like GET, POST, PUT, DELETE) on a view or resource. They run after authentication, but before any view logic.
You can define permissions:
Globally (in
settings.py
)Per View / ViewSet. This is recommended when you want different rules for different endpoints)
Global permissions: In settings.py
:
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
]
}
Common Built-in Permissions include:
1. AllowAny
: Anyone can access. No authentication needed.
from rest_framework.permissions import AllowAny
class MyView(APIView):
permission_classes = [AllowAny]
Useful for public endpoints like signup, homepage, etc.
2. IsAuthenticated
: Only logged-in users are allowed.
from rest_framework.permissions import IsAuthenticated
class MyView(APIView):
permission_classes = [IsAuthenticated]
Useful for most protected APIs.
3. IsAdminUser
: Only users with is_staff=True
can access.
from rest_framework.permissions import IsAdminUser
class AdminOnlyView(APIView):
permission_classes = [IsAdminUser]
4. IsAuthenticatedOrReadOnly
GET (read-only) allowed for everyone
POST, PUT, DELETE require login
from rest_framework.permissions import IsAuthenticatedOrReadOnly
class BookViewSet(ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
permission_classes = [IsAuthenticatedOrReadOnly]
Useful for public blogs or articles where anyone can read, but only logged-in users can write.
You can create your own permission class, as well (Custom Permissions)
from rest_framework.permissions import BasePermission
class IsOwnerOrReadOnly(BasePermission):
def has_object_permission(self, request, view, obj):
# SAFE_METHODS: GET, HEAD, OPTIONS
if request.method in ['GET', 'HEAD', 'OPTIONS']:
return True
return obj.owner == request.user
Use it like this:
class BookViewSet(ModelViewSet):
permission_classes = [IsOwnerOrReadOnly]
This means:
Anyone can read any book
But only the owner can edit/delete it
Filtering
Filtering allows clients to narrow down results — for example, get all books by a specific author.
Here’s how to do basic filtering with filters_backend
.
First, install Django Filter:
pip install django-filter
Add to your settings.py
:
INSTALLED_APPS = [
...,
'django_filters',
]
REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend']
}
Let’s try to filter our books by author
and published
:
In your views.py
:
from django_filters.rest_framework import DjangoFilterBackend
from .models import Book
from .serializers import BookSerializer
from rest_framework import viewsets
class BookListView(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ['author', 'published']
Now you can filter like:
GET /api/books/?author=George%20Orwell
GET /api/books/?published=1945-08-17
Pagination
Let’s limit how many records are returned in one response. This is helpful in situations where you have thousands of entries. This way your API doesn’t overload clients with 10,000 entries, instead they get the limit you set in groups (pages).
Enable Pagination in settings.py
:
REST_FRAMEWORK = {
# ...,
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 3,
}
Now API responses look like this:
{
"count": 57,
"next": "http://api.example.org/books/?page=2",
"previous": null,
"results": [
{
"title": "Book 1",
"author": "Author 1"
},
...
]
}
To get the next page: http://localhost:8000/library/books/?page=2
Throttling
Throttling protects your API from being overwhelmed by too many requests from the same user or IP.
In settings.py
:
REST_FRAMEWORK = {
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.UserRateThrottle',
'rest_framework.throttling.AnonRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'user': '100/day',
'anon': '10/hour',
}
}
This means:
Authenticated users can make 100 requests per day
Anonymous users can make 10 per hour
If the limit is hit, DRF returns:
{
"detail": "Request was throttled. Expected available in 3600 seconds."
}
Conclusion
Over the past four parts of this series, we’ve taken a deep, foundational look at Django and Django REST Framework — from project setup and app structure to models, serializers, views, permissions, and beyond. Instead of rushing to build a project, this series focused on understanding how Django works under the hood, and why it’s designed the way it is.
You’ve seen:
How Django gives you a complete toolkit out of the box: routing, templating, ORM, admin, and authentication
How DRF extends that foundation to build flexible, production-ready APIs
How to use serializers for validation and transformation
How to handle views — from function-based to class-based and viewsets
And how to add powerful features like permissions, authentication, filtering, pagination, and throttling
If you’ve followed this far, you’re not just “aware” of Django — you now have mental models and working knowledge to build real-world APIs confidently.
What’s Next?
You may have noticed we didn’t wrap all this up into a complete project — and that’s by design. This series was meant to lay a strong foundation. But in the next article, we’ll build a real API-driven application from scratch using everything we’ve learned here.
Thanks for sticking with it. This is dense material, but getting comfortable with Django and DRF is a serious level-up for any backend (or fullstack) developer.
Until the next one👋
Subscribe to my newsletter
Read articles from Tito Adeoye directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
