Python

Building a REST API with Django REST Framework - A Practical Guide

Building a REST API with Django REST Framework - A Practical Guide

What Is Django REST Framework — and Why Should You Use It?

Django is an excellent web framework, but it was built for server-rendered applications. When you need to expose data to a mobile app, a React frontend, or a third-party integration, you need an API — and that's where Django REST Framework (DRF) comes in.

DRF is not a replacement for Django. It's a powerful toolkit that sits on top of it, adding the serialization, authentication, permissions, throttling, and viewset patterns that API development demands. It's been battle-tested in production systems at scale, has exceptional documentation, and has one of the largest communities in the Python ecosystem.

If you're building anything beyond a simple server-rendered Django site, learning DRF is not optional — it's the standard.

Prerequisites

This guide assumes you have working knowledge of Django — models, views, URLs, and the ORM. You don't need to be an expert, but you should have built at least one Django project before. You'll also need Python 3.8+ and a virtual environment set up.

Installation and Project Setup

Start with a clean virtual environment and install both Django and DRF:

pip install django djangorestframework

Create a new Django project and app:

django-admin startproject myproject .
python manage.py startapp api

Register both the app and DRF in settings.py:

INSTALLED_APPS = [
    ...
    "rest_framework",
    "api",
]

At the bottom of settings.py, add the global DRF configuration block. You'll come back to fill this in as the project grows:

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}

Your First Model

Throughout this guide we'll build a simple product catalogue API. Define the model in api/models.py:

from django.db import models

class Category(models.Model):
    name = models.CharField(max_length=100)

    def __str__(self):
        return self.name


class Product(models.Model):
    category = models.ForeignKey(Category, on_delete=models.CASCADE, related_name="products")
    name = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    in_stock = models.BooleanField(default=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name

Run the migrations:

python manage.py makemigrations
python manage.py migrate

Understanding Serializers

Serializers are the backbone of DRF. They do two jobs: they convert model instances into Python primitives that can be rendered as JSON (serialization), and they validate and convert incoming data back into model instances (deserialization). Think of them as Django forms, but for APIs.

Create api/serializers.py:

from rest_framework import serializers
from .models import Category, Product


class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = Category
        fields = ["id", "name"]


class ProductSerializer(serializers.ModelSerializer):
    category_name = serializers.CharField(source="category.name", read_only=True)

    class Meta:
        model = Product
        fields = ["id", "name", "description", "price", "in_stock", "category", "category_name", "created_at"]
        read_only_fields = ["created_at"]

Notice category_name — this is a read-only computed field using source. It lets the API return the category name alongside the category ID without a second request from the client. Small details like this dramatically improve the developer experience for whoever is consuming your API.

Custom Validation

Validation lives in the serializer, not the view. DRF gives you two levels of control: field-level validation with validate_<fieldname> methods, and object-level validation with validate() for cross-field rules.

class ProductSerializer(serializers.ModelSerializer):
    ...

    def validate_price(self, value):
        if value <= 0:
            raise serializers.ValidationError("Price must be a positive value.")
        return value

    def validate(self, data):
        if data.get("in_stock") and not data.get("price"):
            raise serializers.ValidationError("In-stock products must have a price.")
        return data

Nested Serializers

Sometimes you want to return related data inline rather than just an ID. Use a nested serializer:

class ProductSerializer(serializers.ModelSerializer):
    category = CategorySerializer(read_only=True)
    category_id = serializers.PrimaryKeyRelatedField(
        queryset=Category.objects.all(), source="category", write_only=True
    )

    class Meta:
        model = Product
        fields = ["id", "name", "price", "category", "category_id"]

This pattern — a nested serializer for reads, a PrimaryKeyRelatedField for writes — is a clean solution to a very common API design problem. Reads return the full category object; writes accept a simple integer ID.

Views: Function-Based, Class-Based, and ViewSets

DRF gives you three ways to write views, each suited to different situations. Understanding when to use each one is what separates a well-structured API from a messy one.

API Views (Function-Based)

The @api_view decorator is the simplest entry point. It wraps a standard Django function view with DRF's request/response handling:

from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import status
from .models import Product
from .serializers import ProductSerializer


@api_view(["GET", "POST"])
def product_list(request):
    if request.method == "GET":
        products = Product.objects.select_related("category").all()
        serializer = ProductSerializer(products, many=True)
        return Response(serializer.data)

    elif request.method == "POST":
        serializer = ProductSerializer(data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data, status=status.HTTP_201_CREATED)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

Note the select_related("category") call on the queryset. This is a critical habit — without it, Django fires a separate SQL query for every product's category, leading to the N+1 query problem that kills API performance at scale.

Class-Based API Views

For more structure, inherit from APIView. Each HTTP method becomes its own method on the class:

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.shortcuts import get_object_or_404


class ProductDetail(APIView):
    def get(self, request, pk):
        product = get_object_or_404(Product, pk=pk)
        serializer = ProductSerializer(product)
        return Response(serializer.data)

    def put(self, request, pk):
        product = get_object_or_404(Product, pk=pk)
        serializer = ProductSerializer(product, data=request.data)
        if serializer.is_valid():
            serializer.save()
            return Response(serializer.data)
        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

    def delete(self, request, pk):
        product = get_object_or_404(Product, pk=pk)
        product.delete()
        return Response(status=status.HTTP_204_NO_CONTENT)

Generic Views

DRF ships with a set of generic views that handle the most common patterns. Instead of writing the list/create logic yourself, inherit from the appropriate generic:

from rest_framework import generics


class ProductList(generics.ListCreateAPIView):
    queryset = Product.objects.select_related("category").all()
    serializer_class = ProductSerializer


class ProductDetail(generics.RetrieveUpdateDestroyAPIView):
    queryset = Product.objects.select_related("category").all()
    serializer_class = ProductSerializer

Two classes. Full CRUD. This is the sweet spot for most straightforward resources — you get all the standard behaviour with very little code, and you can override individual methods when you need custom logic.

ViewSets and Routers

ViewSets take the generic view pattern one step further by combining the list and detail views into a single class. Pair a ViewSet with a Router and URL registration becomes automatic:

from rest_framework import viewsets
from rest_framework.routers import DefaultRouter


class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.select_related("category").all()
    serializer_class = ProductSerializer

    def get_queryset(self):
        queryset = super().get_queryset()
        category = self.request.query_params.get("category")
        in_stock = self.request.query_params.get("in_stock")

        if category:
            queryset = queryset.filter(category__name__icontains=category)
        if in_stock is not None:
            queryset = queryset.filter(in_stock=in_stock.lower() == "true")

        return queryset


router = DefaultRouter()
router.register(r"products", ProductViewSet)
router.register(r"categories", CategoryViewSet)

Then in urls.py:

from django.urls import path, include

urlpatterns = [
    path("api/", include(router.urls)),
]

The router automatically generates these endpoints for each registered viewset:

Method URL Action
GET /api/products/ List all products
POST /api/products/ Create a product
GET /api/products/{id}/ Retrieve a product
PUT / PATCH /api/products/{id}/ Update a product
DELETE /api/products/{id}/ Delete a product

Custom Actions on ViewSets

ViewSets aren't limited to CRUD. Use the @action decorator to add custom endpoints directly to your viewset:

from rest_framework.decorators import action
from rest_framework.response import Response


class ProductViewSet(viewsets.ModelViewSet):
    ...

    @action(detail=False, methods=["get"], url_path="in-stock")
    def in_stock(self, request):
        products = self.get_queryset().filter(in_stock=True)
        serializer = self.get_serializer(products, many=True)
        return Response(serializer.data)

    @action(detail=True, methods=["post"], url_path="mark-out-of-stock")
    def mark_out_of_stock(self, request, pk=None):
        product = self.get_object()
        product.in_stock = False
        product.save()
        return Response({"status": "updated"})

This generates /api/products/in-stock/ and /api/products/{id}/mark-out-of-stock/ automatically. It keeps related logic on the right resource without creating separate views.

Authentication

Authentication identifies who is making a request. DRF supports multiple schemes and you can use different ones on different endpoints.

Session Authentication

Works out of the box — useful during development and for browser-based clients using Django's session cookie. Not suitable for mobile apps or cross-origin API consumers.

Token Authentication

DRF's built-in token auth issues a permanent token per user. Simple to implement, but the lack of expiry is a security concern for production systems:

# settings.py
INSTALLED_APPS += ["rest_framework.authtoken"]
# urls.py
from rest_framework.authtoken.views import obtain_auth_token

urlpatterns += [
    path("api/auth/token/", obtain_auth_token),
]

Clients include the token in subsequent requests as a header:

Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b

JWT Authentication (Recommended for Production)

JSON Web Tokens are the standard for production APIs. They are stateless, expire automatically, and support refresh flows. Install djangorestframework-simplejwt:

pip install djangorestframework-simplejwt
# settings.py
from datetime import timedelta

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ],
}

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    "ROTATE_REFRESH_TOKENS": True,
}
# urls.py
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView

urlpatterns += [
    path("api/auth/token/", TokenObtainPairView.as_view()),
    path("api/auth/token/refresh/", TokenRefreshView.as_view()),
]

The client posts credentials to /api/auth/token/ and receives an access token (short-lived) and a refresh token (long-lived). When the access token expires, the client exchanges the refresh token at /api/auth/token/refresh/ for a new pair — without asking the user to log in again.

Permissions

Where authentication asks who are you?, permissions ask what are you allowed to do? DRF separates these concerns cleanly.

Built-in Permission Classes

Class Behaviour
AllowAny Unrestricted access — use for public endpoints
IsAuthenticated Any authenticated user
IsAdminUser Staff users only (is_staff=True)
IsAuthenticatedOrReadOnly Read access for everyone, write access for authenticated users

Per-View Permission Overrides

Global defaults are set in REST_FRAMEWORK, but any view can override them:

from rest_framework.permissions import IsAuthenticated, IsAdminUser, AllowAny


class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

    def get_permissions(self):
        if self.action in ["list", "retrieve"]:
            return [AllowAny()]
        elif self.action in ["create", "update", "partial_update", "destroy"]:
            return [IsAdminUser()]
        return [IsAuthenticated()]

Custom Permission Classes

For real applications, you'll almost always need custom permissions. The interface is simple — two methods to implement:

from rest_framework.permissions import BasePermission


class IsOwnerOrReadOnly(BasePermission):
    """
    Allow read access to anyone. Restrict write access to the object's owner.
    """
    def has_object_permission(self, request, view, obj):
        if request.method in ("GET", "HEAD", "OPTIONS"):
            return True
        return obj.owner == request.user

has_permission runs on every request to the view. has_object_permission runs only when a specific object is being accessed. Use both together for fine-grained control.

Filtering, Searching, and Ordering

A list endpoint that returns every record is rarely useful. DRF integrates with django-filter to give you powerful, declarative filtering with almost no code.

pip install django-filter
# settings.py
INSTALLED_APPS += ["django_filters"]

REST_FRAMEWORK = {
    ...
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],
}
import django_filters
from .models import Product


class ProductFilter(django_filters.FilterSet):
    min_price = django_filters.NumberFilter(field_name="price", lookup_expr="gte")
    max_price = django_filters.NumberFilter(field_name="price", lookup_expr="lte")
    category = django_filters.CharFilter(field_name="category__name", lookup_expr="icontains")

    class Meta:
        model = Product
        fields = ["in_stock", "category", "min_price", "max_price"]


class ProductViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.select_related("category").all()
    serializer_class = ProductSerializer
    filterset_class = ProductFilter
    search_fields = ["name", "description"]
    ordering_fields = ["price", "created_at"]
    ordering = ["-created_at"]

Your API now supports queries like:

GET /api/products/?min_price=10&max_price=50&in_stock=true
GET /api/products/?search=wireless
GET /api/products/?ordering=-price

Pagination

Always paginate list endpoints. Returning unbounded querysets is one of the most common causes of slow APIs and database overload in production.

DRF ships with three pagination styles. PageNumberPagination is the most common:

# pagination.py
from rest_framework.pagination import PageNumberPagination


class StandardResultsPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = "page_size"
    max_page_size = 100
# settings.py
REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "api.pagination.StandardResultsPagination",
    "PAGE_SIZE": 20,
}

The response will automatically include count, next, previous, and results fields — everything a client needs to implement pagination in the UI.

For cursor-based pagination (better for large, frequently updated datasets), use CursorPagination instead. It's more complex to implement but eliminates the page drift problem that plagues offset-based pagination.

Throttling

Throttling limits how many requests a client can make in a given period. It protects your API from abuse, accidental runaway scripts, and denial-of-service attempts. Don't ship a production API without it.

REST_FRAMEWORK = {
    ...
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "100/day",
        "user": "1000/day",
    },
}

You can also define custom throttle scopes for specific views:

from rest_framework.throttling import UserRateThrottle


class BurstRateThrottle(UserRateThrottle):
    scope = "burst"


class ProductViewSet(viewsets.ModelViewSet):
    throttle_classes = [BurstRateThrottle]
    ...
# settings.py
"DEFAULT_THROTTLE_RATES": {
    "burst": "60/min",
    "user": "1000/day",
}

Versioning

APIs change over time. Versioning lets you introduce breaking changes without breaking existing clients. DRF supports several versioning strategies — URL path versioning is the most explicit and easiest to reason about:

# settings.py
REST_FRAMEWORK = {
    ...
    "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
    "DEFAULT_VERSION": "v1",
    "ALLOWED_VERSIONS": ["v1", "v2"],
}
# urls.py
urlpatterns = [
    path("api/<version>/", include(router.urls)),
]

Inside a view, request.version tells you which version the client requested, so you can branch logic or return different serializers accordingly:

def get_serializer_class(self):
    if self.request.version == "v2":
        return ProductSerializerV2
    return ProductSerializer

Testing Your API

DRF ships with APIClient, a test client built specifically for APIs. The surface area of a REST API is well-defined and highly testable — there is no excuse for skipping tests.

from rest_framework.test import APITestCase, APIClient
from rest_framework import status
from django.contrib.auth.models import User
from .models import Category, Product


class ProductAPITests(APITestCase):

    def setUp(self):
        self.client = APIClient()
        self.user = User.objects.create_user(username="testuser", password="password")
        self.admin = User.objects.create_superuser(username="admin", password="password")
        self.category = Category.objects.create(name="Electronics")
        self.product = Product.objects.create(
            name="Laptop",
            price="999.99",
            category=self.category,
        )

    def test_list_products_unauthenticated(self):
        response = self.client.get("/api/products/")
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_create_product_requires_admin(self):
        self.client.force_authenticate(user=self.user)
        data = {"name": "Mouse", "price": "29.99", "category": self.category.id}
        response = self.client.post("/api/products/", data)
        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

    def test_admin_can_create_product(self):
        self.client.force_authenticate(user=self.admin)
        data = {"name": "Mouse", "price": "29.99", "category": self.category.id}
        response = self.client.post("/api/products/", data)
        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
        self.assertEqual(response.data["name"], "Mouse")

    def test_invalid_price_returns_400(self):
        self.client.force_authenticate(user=self.admin)
        data = {"name": "Mouse", "price": "-5.00", "category": self.category.id}
        response = self.client.post("/api/products/", data)
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
        self.assertIn("price", response.data)
python manage.py test api

Production Checklist

Before you ship, run through these:

Item Why It Matters
JWT with short-lived access tokens Permanent tokens are a security liability
Explicit permissions on every view Never rely on global defaults alone
Pagination on all list endpoints Unbounded queries cause timeouts
Throttling for anonymous and authenticated users Protects against abuse and runaway clients
select_related / prefetch_related on querysets Prevents N+1 query problems
HTTPS enforced Tokens in plain HTTP are trivially intercepted
CORS configured via django-cors-headers Required for browser-based clients on different domains
API versioning in place from day one Much harder to add retroactively

One final note: DRF's browsable API is enabled by default and is invaluable during development — it lets you interact with your API in the browser without any tooling. In production, consider disabling it by removing BrowsableAPIRenderer from DEFAULT_RENDERER_CLASSES to reduce the attack surface and avoid leaking your API structure to unauthenticated visitors.

Florin Pinta

Certified full-stack developer specialising in PHP and Python web systems. From kitchen to code — building production software that runs real businesses.

Need a Custom Web System?

I build production-ready web applications with Laravel, CodeIgniter & Django. Let's talk about your project.