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',
]
Note: Never set 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']
Pro Tip: Always use separate list vs. detail serializers for resources with nested collections. Embedding 20 comments per post in a list of 50 posts serializes 1,000 comment objects per request — a hidden latency killer.

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..."}'
Note: On web clients, store the access token in memory (a JS variable) and the refresh token in an 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

StrategyBest ForTrade-off
PageNumberPaginationAdmin UIs, numbered page controlsExpensive COUNT(*) on large tables
LimitOffsetPaginationSimple clients, arbitrary jumpsSame COUNT(*) cost; items shift if inserts happen
CursorPaginationInfinite scroll, real-time feedsCannot 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
Pro Tip: Use 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'))
)
Note: Install 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 APIView vs ViewSet?
Use APIView for one-off endpoints with non-standard behavior — webhook receivers, OAuth callbacks, or aggregation endpoints that don't map to a single model. Use ViewSet (ideally ModelViewSet) 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. Set DEFAULT_VERSIONING_CLASS = 'rest_framework.versioning.URLPathVersioning' and read request.version inside 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 FileField or ImageField to the model and serializer. Set parser_classes = [MultiPartParser, FormParser] on the view. For large files, use django-chunked-upload and store the final file on S3 via django-storages. Never store large files in the database.
How do I write tests for DRF views?
Use DRF's APITestCase and APIClient. Call client.force_authenticate(user=user) to bypass the token flow in unit tests. For view-level isolation, use APIRequestFactory — 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 the validate method?
validate_<field> runs after the field's type coercion and handles single-field constraints (length, format, uniqueness). The top-level validate(data) method receives the full validated data dict and is the right place for cross-field rules — for example, ensuring end_date > start_date or that exactly one of two optional fields is provided.