
Introduction
Building REST APIs efficiently is a critical skill for modern backend developers. Python’s Django Rest Framework (DRF) makes it easier than ever to create secure, scalable, and maintainable APIs. DRF adds powerful tools—serializers, viewsets, routers, authentication, and permissions—on top of Django’s robust foundation. In this comprehensive guide, we’ll build a complete task management API with authentication, permissions, validation, filtering, and pagination to demonstrate production-ready DRF patterns.
What Is Django Rest Framework?
Django Rest Framework (DRF) is a flexible and feature-rich toolkit for building Web APIs. It provides:
- Serializers – Convert complex data types to JSON and validate input
- ViewSets – Combine CRUD logic in a single class
- Routers – Automatically generate URL patterns
- Authentication – Token, Session, JWT, and custom auth
- Permissions – Fine-grained access control
- Browsable API – Interactive documentation for free
Project Setup
# Create virtual environment
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Install dependencies
pip install django djangorestframework djangorestframework-simplejwt django-filter
# Create project and app
django-admin startproject taskapi .
python manage.py startapp tasks
# settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'rest_framework_simplejwt',
'django_filters',
'tasks',
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter',
],
}
from datetime import timedelta
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
}
Models
# tasks/models.py
from django.db import models
from django.contrib.auth.models import User
from django.core.validators import MinLengthValidator
class Project(models.Model):
name = models.CharField(max_length=200, validators=[MinLengthValidator(3)])
description = models.TextField(blank=True)
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='projects')
members = models.ManyToManyField(User, related_name='member_projects', blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.name
class Task(models.Model):
class Priority(models.TextChoices):
LOW = 'low', 'Low'
MEDIUM = 'medium', 'Medium'
HIGH = 'high', 'High'
URGENT = 'urgent', 'Urgent'
class Status(models.TextChoices):
TODO = 'todo', 'To Do'
IN_PROGRESS = 'in_progress', 'In Progress'
REVIEW = 'review', 'Review'
DONE = 'done', 'Done'
title = models.CharField(max_length=200, validators=[MinLengthValidator(3)])
description = models.TextField(blank=True)
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='tasks')
assignee = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True, related_name='assigned_tasks'
)
created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='created_tasks')
priority = models.CharField(
max_length=10, choices=Priority.choices, default=Priority.MEDIUM
)
status = models.CharField(
max_length=15, choices=Status.choices, default=Status.TODO
)
due_date = models.DateField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return self.title
class Comment(models.Model):
task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name='comments')
author = models.ForeignKey(User, on_delete=models.CASCADE)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f'Comment by {self.author.username} on {self.task.title}'
Serializers
# tasks/serializers.py
from rest_framework import serializers
from django.contrib.auth.models import User
from .models import Project, Task, Comment
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'email', 'first_name', 'last_name']
read_only_fields = ['id']
class UserRegistrationSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=8)
password_confirm = serializers.CharField(write_only=True)
class Meta:
model = User
fields = ['username', 'email', 'password', 'password_confirm', 'first_name', 'last_name']
def validate(self, attrs):
if attrs['password'] != attrs['password_confirm']:
raise serializers.ValidationError({'password_confirm': 'Passwords do not match'})
return attrs
def create(self, validated_data):
validated_data.pop('password_confirm')
user = User.objects.create_user(**validated_data)
return user
class CommentSerializer(serializers.ModelSerializer):
author = UserSerializer(read_only=True)
class Meta:
model = Comment
fields = ['id', 'author', 'content', 'created_at']
read_only_fields = ['id', 'author', 'created_at']
class TaskSerializer(serializers.ModelSerializer):
assignee = UserSerializer(read_only=True)
assignee_id = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(), source='assignee', write_only=True, required=False
)
created_by = UserSerializer(read_only=True)
comments_count = serializers.SerializerMethodField()
class Meta:
model = Task
fields = [
'id', 'title', 'description', 'project', 'assignee', 'assignee_id',
'created_by', 'priority', 'status', 'due_date', 'comments_count',
'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_by', 'created_at', 'updated_at']
def get_comments_count(self, obj):
return obj.comments.count()
def validate_due_date(self, value):
from django.utils import timezone
if value and value < timezone.now().date():
raise serializers.ValidationError('Due date cannot be in the past')
return value
class TaskDetailSerializer(TaskSerializer):
comments = CommentSerializer(many=True, read_only=True)
class Meta(TaskSerializer.Meta):
fields = TaskSerializer.Meta.fields + ['comments']
class ProjectSerializer(serializers.ModelSerializer):
owner = UserSerializer(read_only=True)
members = UserSerializer(many=True, read_only=True)
member_ids = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(), source='members', many=True, write_only=True, required=False
)
tasks_count = serializers.SerializerMethodField()
class Meta:
model = Project
fields = [
'id', 'name', 'description', 'owner', 'members', 'member_ids',
'tasks_count', 'created_at', 'updated_at'
]
read_only_fields = ['id', 'owner', 'created_at', 'updated_at']
def get_tasks_count(self, obj):
return obj.tasks.count()
class ProjectDetailSerializer(ProjectSerializer):
tasks = TaskSerializer(many=True, read_only=True)
class Meta(ProjectSerializer.Meta):
fields = ProjectSerializer.Meta.fields + ['tasks']
Custom Permissions
# tasks/permissions.py
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
"""Allow owners to edit, others can only read."""
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.owner == request.user
class IsProjectMember(permissions.BasePermission):
"""Allow project members to access tasks."""
def has_permission(self, request, view):
if request.method == 'POST':
project_id = request.data.get('project')
if project_id:
from .models import Project
try:
project = Project.objects.get(id=project_id)
return (
project.owner == request.user or
request.user in project.members.all()
)
except Project.DoesNotExist:
return False
return True
def has_object_permission(self, request, view, obj):
project = obj.project if hasattr(obj, 'project') else obj
return (
project.owner == request.user or
request.user in project.members.all()
)
class IsCommentAuthorOrProjectMember(permissions.BasePermission):
"""Allow comment author or project members to manage comments."""
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
project = obj.task.project
return (
project.owner == request.user or
request.user in project.members.all()
)
return obj.author == request.user
ViewSets
# tasks/views.py
from rest_framework import viewsets, status, filters
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny
from django_filters.rest_framework import DjangoFilterBackend
from django.contrib.auth.models import User
from .models import Project, Task, Comment
from .serializers import (
ProjectSerializer, ProjectDetailSerializer,
TaskSerializer, TaskDetailSerializer,
CommentSerializer, UserSerializer, UserRegistrationSerializer
)
from .permissions import IsOwnerOrReadOnly, IsProjectMember, IsCommentAuthorOrProjectMember
from .filters import TaskFilter
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
def get_permissions(self):
if self.action == 'create':
return [AllowAny()]
return [IsAuthenticated()]
def get_serializer_class(self):
if self.action == 'create':
return UserRegistrationSerializer
return UserSerializer
@action(detail=False, methods=['get'])
def me(self, request):
serializer = self.get_serializer(request.user)
return Response(serializer.data)
class ProjectViewSet(viewsets.ModelViewSet):
serializer_class = ProjectSerializer
permission_classes = [IsAuthenticated, IsOwnerOrReadOnly]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ['name', 'description']
ordering_fields = ['created_at', 'name']
def get_queryset(self):
user = self.request.user
return Project.objects.filter(
models.Q(owner=user) | models.Q(members=user)
).distinct()
def get_serializer_class(self):
if self.action == 'retrieve':
return ProjectDetailSerializer
return ProjectSerializer
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
@action(detail=True, methods=['post'])
def add_member(self, request, pk=None):
project = self.get_object()
user_id = request.data.get('user_id')
try:
user = User.objects.get(id=user_id)
project.members.add(user)
return Response({'status': 'member added'})
except User.DoesNotExist:
return Response(
{'error': 'User not found'},
status=status.HTTP_404_NOT_FOUND
)
@action(detail=True, methods=['post'])
def remove_member(self, request, pk=None):
project = self.get_object()
user_id = request.data.get('user_id')
try:
user = User.objects.get(id=user_id)
project.members.remove(user)
return Response({'status': 'member removed'})
except User.DoesNotExist:
return Response(
{'error': 'User not found'},
status=status.HTTP_404_NOT_FOUND
)
from django.db import models
class TaskViewSet(viewsets.ModelViewSet):
serializer_class = TaskSerializer
permission_classes = [IsAuthenticated, IsProjectMember]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_class = TaskFilter
search_fields = ['title', 'description']
ordering_fields = ['created_at', 'due_date', 'priority', 'status']
def get_queryset(self):
user = self.request.user
return Task.objects.filter(
models.Q(project__owner=user) | models.Q(project__members=user)
).distinct().select_related('project', 'assignee', 'created_by')
def get_serializer_class(self):
if self.action == 'retrieve':
return TaskDetailSerializer
return TaskSerializer
def perform_create(self, serializer):
serializer.save(created_by=self.request.user)
@action(detail=True, methods=['post'])
def assign(self, request, pk=None):
task = self.get_object()
user_id = request.data.get('user_id')
try:
user = User.objects.get(id=user_id)
task.assignee = user
task.save()
return Response(TaskSerializer(task).data)
except User.DoesNotExist:
return Response(
{'error': 'User not found'},
status=status.HTTP_404_NOT_FOUND
)
@action(detail=True, methods=['post'])
def change_status(self, request, pk=None):
task = self.get_object()
new_status = request.data.get('status')
if new_status not in dict(Task.Status.choices):
return Response(
{'error': 'Invalid status'},
status=status.HTTP_400_BAD_REQUEST
)
task.status = new_status
task.save()
return Response(TaskSerializer(task).data)
class CommentViewSet(viewsets.ModelViewSet):
serializer_class = CommentSerializer
permission_classes = [IsAuthenticated, IsCommentAuthorOrProjectMember]
def get_queryset(self):
return Comment.objects.filter(
task_id=self.kwargs.get('task_pk')
).select_related('author')
def perform_create(self, serializer):
task_id = self.kwargs.get('task_pk')
serializer.save(author=self.request.user, task_id=task_id)
Custom Filters
# tasks/filters.py
import django_filters
from .models import Task
class TaskFilter(django_filters.FilterSet):
project = django_filters.NumberFilter(field_name='project__id')
assignee = django_filters.NumberFilter(field_name='assignee__id')
priority = django_filters.ChoiceFilter(choices=Task.Priority.choices)
status = django_filters.ChoiceFilter(choices=Task.Status.choices)
due_date_before = django_filters.DateFilter(field_name='due_date', lookup_expr='lte')
due_date_after = django_filters.DateFilter(field_name='due_date', lookup_expr='gte')
created_after = django_filters.DateTimeFilter(field_name='created_at', lookup_expr='gte')
overdue = django_filters.BooleanFilter(method='filter_overdue')
class Meta:
model = Task
fields = ['project', 'assignee', 'priority', 'status']
def filter_overdue(self, queryset, name, value):
from django.utils import timezone
if value:
return queryset.filter(
due_date__lt=timezone.now().date(),
status__in=['todo', 'in_progress', 'review']
)
return queryset
URL Configuration
# tasks/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from rest_framework_nested import routers
from .views import ProjectViewSet, TaskViewSet, CommentViewSet, UserViewSet
router = DefaultRouter()
router.register(r'users', UserViewSet)
router.register(r'projects', ProjectViewSet, basename='project')
router.register(r'tasks', TaskViewSet, basename='task')
# Nested router for comments under tasks
tasks_router = routers.NestedDefaultRouter(router, r'tasks', lookup='task')
tasks_router.register(r'comments', CommentViewSet, basename='task-comments')
urlpatterns = [
path('', include(router.urls)),
path('', include(tasks_router.urls)),
]
# taskapi/urls.py
from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('tasks.urls')),
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
Common Mistakes to Avoid
1. N+1 Query Problems
# Wrong - causes N+1 queries
class TaskViewSet(viewsets.ModelViewSet):
queryset = Task.objects.all()
# Correct - use select_related and prefetch_related
class TaskViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return Task.objects.select_related(
'project', 'assignee', 'created_by'
).prefetch_related('comments')
2. Not Validating Nested Data
# Wrong - no validation on write
class TaskSerializer(serializers.ModelSerializer):
assignee = UserSerializer()
# Correct - separate read/write fields
class TaskSerializer(serializers.ModelSerializer):
assignee = UserSerializer(read_only=True)
assignee_id = serializers.PrimaryKeyRelatedField(
queryset=User.objects.all(),
source='assignee',
write_only=True
)
3. Missing Permission Checks
# Wrong - anyone can access any task
class TaskViewSet(viewsets.ModelViewSet):
queryset = Task.objects.all()
# Correct - filter by user permissions
class TaskViewSet(viewsets.ModelViewSet):
def get_queryset(self):
user = self.request.user
return Task.objects.filter(
Q(project__owner=user) | Q(project__members=user)
)
Final Thoughts
Django Rest Framework remains one of the most powerful and developer-friendly solutions for building REST APIs in Python. Its combination of serializers, viewsets, permissions, and built-in features creates a productive environment for fast, clean backend development. Start with simple endpoints, then add authentication, permissions, and filtering as your API grows. For high-performance async requirements, consider FastAPI, but for full-featured web applications with admin panels and complex business logic, DRF is hard to beat.
For more Python backend development, read How to Build a REST API with FastAPI. For official documentation and best practices, visit the Django Rest Framework Documentation and Django Documentation.