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
BrowsableAPIRendererfromDEFAULT_RENDERER_CLASSESto reduce the attack surface and avoid leaking your API structure to unauthenticated visitors.