Django REST Framework: Building APIs with Python (2026)
Published June 6, 2026 • 14 min read
Django REST Framework (DRF) is the de-facto standard for building production-grade APIs in Python. It layers serialization, authentication, permissions, filtering, pagination, and throttling on top of Django — letting you ship a full-featured API without reinventing every wheel. This guide walks through every major layer using a Blog API (Posts + Comments) as the running example.
Installation and Global Settings
Install DRF and companion packages you'll use throughout this guide:
pip install django djangorestframework djangorestframework-simplejwt \
django-filter django-cors-headers drf-nested-routers
Register the apps and wire up the global DRF configuration in settings.py:
# settings.py
from datetime import timedelta
INSTALLED_APPS = [
# ... django defaults ...
'rest_framework',
'rest_framework_simplejwt',
'django_filters',
'corsheaders',
'blog', # your app
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', # must be before CommonMiddleware
# ... rest of middleware ...
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/day',
'user': '1000/day',
},
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
'ALGORITHM': 'HS256',
}
CORS_ALLOWED_ORIGINS = [
'https://yourfrontend.com',
]
CORS_ALLOW_ALL_ORIGINS = True in production. List only the exact origins your frontend runs on.Blog Models
The Post and Comment models power every example in this guide. They cover the most common relational patterns: ForeignKey to auth User, nullable FK to a Category, and a reverse relation for comments.
# blog/models.py
from django.db import models
from django.contrib.auth.models import User
class Category(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(unique=True)
def __str__(self):
return self.name
class Post(models.Model):
STATUS = [('draft', 'Draft'), ('published', 'Published')]
title = models.CharField(max_length=255)
slug = models.SlugField(unique=True)
content = models.TextField()
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True)
status = models.CharField(max_length=10, choices=STATUS, default='draft')
published_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
indexes = [models.Index(fields=['-created_at'])]
def __str__(self):
return self.title
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
author = models.ForeignKey(User, on_delete=models.CASCADE)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
Serializers — Flat and Nested
DRF serializers handle deserialization (input validation) and serialization (output representation). Use separate serializers for list and detail views to avoid expensive nested queries on every list request.
# blog/serializers.py
from rest_framework import serializers
from django.contrib.auth.models import User
from .models import Category, Post, Comment
class AuthorSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'first_name', 'last_name']
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = ['id', 'name', 'slug']
class CommentSerializer(serializers.ModelSerializer):
author = AuthorSerializer(read_only=True)
class Meta:
model = Comment
fields = ['id', 'author', 'body', 'created_at']
read_only_fields = ['created_at']
class PostListSerializer(serializers.ModelSerializer):
"""Lightweight — no embedded comments. Used for list endpoints."""
author = AuthorSerializer(read_only=True)
category = CategorySerializer(read_only=True)
# Write-only FK so clients can set category by ID
category_id = serializers.PrimaryKeyRelatedField(
queryset=Category.objects.all(), source='category', write_only=True, required=False
)
comment_count = serializers.SerializerMethodField()
class Meta:
model = Post
fields = ['id', 'title', 'slug', 'author', 'category', 'category_id',
'status', 'published_at', 'created_at', 'comment_count']
def get_comment_count(self, obj):
# Works without extra query when annotated in get_queryset
return getattr(obj, 'comment_count', obj.comments.count())
def validate_title(self, value):
if len(value.strip()) < 5:
raise serializers.ValidationError("Title must be at least 5 characters.")
return value.strip()
class PostDetailSerializer(PostListSerializer):
"""Full representation — includes content and nested comments."""
content = serializers.CharField()
comments = CommentSerializer(many=True, read_only=True)
class Meta(PostListSerializer.Meta):
fields = PostListSerializer.Meta.fields + ['content', 'comments', 'updated_at']
ViewSets and Routers — Full CRUD
A ModelViewSet provides list, create, retrieve, update, partial_update, and destroy in one class. The router generates all URL patterns from it.
# blog/views.py
from django.db.models import Count
from django.utils import timezone
from rest_framework import viewsets, permissions, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend
from .models import Post, Comment
from .serializers import PostListSerializer, PostDetailSerializer, CommentSerializer
from .permissions import IsAuthorOrReadOnly
from .filters import PostFilter
from .pagination import PostCursorPagination
class PostViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
filterset_class = PostFilter
search_fields = ['title', 'content']
ordering_fields = ['created_at', 'published_at', 'title']
pagination_class = PostCursorPagination
def get_queryset(self):
return (
Post.objects
.select_related('author', 'category')
.prefetch_related('comments__author')
.annotate(comment_count=Count('comments'))
)
def get_serializer_class(self):
if self.action == 'retrieve':
return PostDetailSerializer
return PostListSerializer
def perform_create(self, serializer):
serializer.save(author=self.request.user)
@action(detail=True, methods=['post'],
permission_classes=[permissions.IsAuthenticated, IsAuthorOrReadOnly])
def publish(self, request, pk=None):
"""Custom action: POST /posts/{id}/publish/"""
post = self.get_object()
if post.status == 'published':
return Response({'detail': 'Already published.'}, status=status.HTTP_400_BAD_REQUEST)
post.status = 'published'
post.published_at = timezone.now()
post.save(update_fields=['status', 'published_at'])
return Response(PostDetailSerializer(post, context={'request': request}).data)
class CommentViewSet(viewsets.ModelViewSet):
serializer_class = CommentSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
def get_queryset(self):
return (
Comment.objects
.filter(post_id=self.kwargs['post_pk'])
.select_related('author')
)
def perform_create(self, serializer):
post = Post.objects.get(pk=self.kwargs['post_pk'])
serializer.save(author=self.request.user, post=post)
# blog/urls.py
from rest_framework.routers import DefaultRouter
from rest_framework_nested.routers import NestedDefaultRouter
from .views import PostViewSet, CommentViewSet
router = DefaultRouter()
router.register(r'posts', PostViewSet, basename='post')
# Nested: /posts/{post_pk}/comments/
posts_router = NestedDefaultRouter(router, r'posts', lookup='post')
posts_router.register(r'comments', CommentViewSet, basename='post-comments')
urlpatterns = router.urls + posts_router.urls
This produces the full URL surface: GET /posts/, POST /posts/, GET /posts/42/, PUT /posts/42/, PATCH /posts/42/, DELETE /posts/42/, POST /posts/42/publish/, and the full comment CRUD under each post.
JWT Authentication with simplejwt
Add the token endpoints to the project URL config:
# project/urls.py
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView, TokenRefreshView, TokenVerifyView
)
urlpatterns = [
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/token/verify/', TokenVerifyView.as_view(), name='token_verify'),
path('api/', include('blog.urls')),
]
Obtain tokens and make authenticated requests:
# Step 1: obtain token pair
curl -s -X POST http://localhost:8000/api/token/ \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"secret123"}' | python -m json.tool
# {"access": "eyJhbGci...", "refresh": "eyJhbGci..."}
# Step 2: call a protected endpoint
curl http://localhost:8000/api/posts/ \
-H "Authorization: Bearer eyJhbGci..."
# Step 3: refresh the access token (no password needed)
curl -X POST http://localhost:8000/api/token/refresh/ \
-H "Content-Type: application/json" \
-d '{"refresh":"eyJhbGci..."}'
HttpOnly cookie. This blocks XSS from stealing the refresh token while keeping the auth flow seamless.Permissions — Built-in and Custom
DRF's built-in permissions cover most cases. For object-level rules, subclass BasePermission and implement has_object_permission.
# blog/permissions.py
from rest_framework import permissions
class IsAuthorOrReadOnly(permissions.BasePermission):
"""Safe methods (GET, HEAD, OPTIONS) are always allowed.
Write methods require the requesting user to be the object's author."""
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.author == request.user
class IsEditorOrAdmin(permissions.BasePermission):
"""Allow access only to members of 'editors' or 'admins' groups."""
message = "You must be an editor or administrator."
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
return request.user.groups.filter(name__in=['editors', 'admins']).exists()
Combine permissions using bitwise operators (DRF 3.14+):
# AND: both must pass
permission_classes = [IsAuthenticated & IsAuthorOrReadOnly]
# OR: either passes
permission_classes = [IsAuthenticated & (IsAuthorOrReadOnly | IsEditorOrAdmin)]
Filtering with django-filter
Define a FilterSet to expose precise query-string filters without polluting your view logic:
# blog/filters.py
import django_filters
from .models import Post
class PostFilter(django_filters.FilterSet):
status = django_filters.ChoiceFilter(choices=Post.STATUS)
category = django_filters.CharFilter(field_name='category__slug', lookup_expr='exact')
author = django_filters.CharFilter(field_name='author__username', lookup_expr='exact')
created_after = django_filters.DateFilter(field_name='created_at', lookup_expr='date__gte')
created_before = django_filters.DateFilter(field_name='created_at', lookup_expr='date__lte')
title_contains = django_filters.CharFilter(field_name='title', lookup_expr='icontains')
class Meta:
model = Post
fields = ['status', 'category', 'author']
Consumers can now combine filters: GET /api/posts/?status=published&category=python&created_after=2026-01-01&ordering=-published_at
Pagination Strategies
| Strategy | Best For | Trade-off |
|---|---|---|
| PageNumberPagination | Admin UIs, numbered page controls | Expensive COUNT(*) on large tables |
| LimitOffsetPagination | Simple clients, arbitrary jumps | Same COUNT(*) cost; items shift if inserts happen |
| CursorPagination | Infinite scroll, real-time feeds | Cannot jump to arbitrary page number |
# blog/pagination.py
from rest_framework.pagination import PageNumberPagination, CursorPagination
class StandardPagePagination(PageNumberPagination):
page_size = 20
page_size_query_param = 'page_size'
max_page_size = 100
class PostCursorPagination(CursorPagination):
page_size = 20
ordering = '-created_at' # must match an indexed, stable sort field
CursorPagination on any endpoint that a mobile app infinite-scrolls through. It is O(1) per page regardless of dataset size and avoids the row-skip bug caused by inserts between page fetches.Throttling
The global config sets default rates. Override at the class level with custom throttles, or apply burst + sustained limits together:
# blog/throttles.py
from rest_framework.throttling import UserRateThrottle
class BurstRateThrottle(UserRateThrottle):
scope = 'burst'
class SustainedRateThrottle(UserRateThrottle):
scope = 'sustained'
# settings.py — add scopes
DEFAULT_THROTTLE_RATES = {
'anon': '200/day',
'user': '2000/day',
'burst': '60/min',
'sustained': '1000/day',
}
# In your ViewSet
class PostViewSet(viewsets.ModelViewSet):
throttle_classes = [BurstRateThrottle, SustainedRateThrottle]
# ... rest of the class ...
Fixing N+1 Queries
N+1 is the most common DRF performance problem. A list of 50 posts with author info generates 51 queries by default: 1 for the posts, then 1 per post to fetch its author.
# BAD — 51 queries for a 50-post page
queryset = Post.objects.all()
# GOOD — 3 queries total regardless of page size
# select_related = SQL JOIN (for ForeignKey / OneToOne)
# prefetch_related = separate query + Python join (for reverse FK / M2M)
queryset = (
Post.objects
.select_related('author', 'category')
.prefetch_related('comments__author')
)
# Annotate counts at DB level — avoids Python-side count() calls
from django.db.models import Count
queryset = (
Post.objects
.select_related('author', 'category')
.annotate(comment_count=Count('comments'))
)
django-debug-toolbar and check the SQL panel during development. Any list endpoint generating more than 5–10 queries is almost certainly suffering from N+1. The django-silk profiler works well in staging environments.Frequently Asked Questions
- When should I use
APIViewvsViewSet? - Use
APIViewfor one-off endpoints with non-standard behavior — webhook receivers, OAuth callbacks, or aggregation endpoints that don't map to a single model. UseViewSet(ideallyModelViewSet) for any resource-oriented endpoint where you need standard CRUD. Routers eliminate repetitive URL code. - How do I version a DRF API?
- URL versioning (
/api/v1/,/api/v2/) is the most explicit and easiest to test. SetDEFAULT_VERSIONING_CLASS = 'rest_framework.versioning.URLPathVersioning'and readrequest.versioninside your views or serializers. Namespace versioning is cleaner at the URL conf level; header versioning is less discoverable but avoids URL clutter. - How do I handle file uploads?
- Add a
FileFieldorImageFieldto the model and serializer. Setparser_classes = [MultiPartParser, FormParser]on the view. For large files, usedjango-chunked-uploadand store the final file on S3 viadjango-storages. Never store large files in the database. - How do I write tests for DRF views?
- Use DRF's
APITestCaseandAPIClient. Callclient.force_authenticate(user=user)to bypass the token flow in unit tests. For view-level isolation, useAPIRequestFactory— it bypasses URL routing and middleware. Keep one integration test per endpoint that goes through the full stack. - What is the difference between
validate_<field>and thevalidatemethod? validate_<field>runs after the field's type coercion and handles single-field constraints (length, format, uniqueness). The top-levelvalidate(data)method receives the full validated data dict and is the right place for cross-field rules — for example, ensuringend_date > start_dateor that exactly one of two optional fields is provided.