Django Backend Foundations: Extending with Django REST Framework

Tito AdeoyeTito Adeoye
17 min read

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:

  1. create a library app

  2. register 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.

💡
Use 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> methods

  • Any validators passed directly to the field

  • And 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

  1. 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
  1. 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')),
  1. 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 book

  • GET /books/1/ → retrieve book with ID 1

  • PATCH /books/1/ → partial update

  • DELETE /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 CaseRecommended View Type
One-off endpoint, simple logicFunction-Based View
Full control over logicAPIView
CRUD with models/serializersGeneric Views
Full REST resource with routingViewSets + 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👋

0
Subscribe to my newsletter

Read articles from Tito Adeoye directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tito Adeoye
Tito Adeoye