Serializers, ViewSets, Routers, Authentication, Permissions, Filtering, Pagination — everything for production Django APIs.
pip install djangorestframework django-filter
pip install djangorestframework-simplejwt # JWT auth# settings.py
INSTALLED_APPS = [
...
'rest_framework',
'rest_framework.authtoken',
'django_filters',
]
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',
],
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
],
'DEFAULT_PARSER_CLASSES': [
'rest_framework.parsers.JSONParser',
'rest_framework.parsers.MultiPartParser',
'rest_framework.parsers.FormParser',
],
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/hour',
'user': '1000/hour',
},
'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.openapi.AutoSchema',
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning',
'DEFAULT_VERSION': 'v1',
'ALLOWED_VERSIONS': ['v1', 'v2'],
}# project/urls.py
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('api/v1/', include('api.urls')),
path('api/auth/', include('rest_framework.urls')), # browsable API login
]DRF ships a self-documenting browsable API at every endpoint when accessed from a browser. No extra tools needed for local debugging.
from rest_framework import serializers
from .models import Article, Comment
class CommentSerializer(serializers.ModelSerializer):
author_name = serializers.CharField(
source='author.username', read_only=True
)
class Meta:
model = Comment
fields = ['id', 'article', 'author_name', 'body', 'created_at']
read_only_fields = ['id', 'author_name', 'created_at']
class ArticleSerializer(serializers.ModelSerializer):
comments = CommentSerializer(many=True, read_only=True)
comment_count = serializers.IntegerField(
source='comments.count', read_only=True
)
status_display = serializers.CharField(
source='get_status_display', read_only=True
)
slug = serializers.SlugField(read_only=True)
class Meta:
model = Article
fields = [
'id', 'title', 'slug', 'body', 'status',
'status_display', 'author', 'comments',
'comment_count', 'tags', 'created_at', 'updated_at',
]
read_only_fields = ['id', 'slug', 'author', 'created_at', 'updated_at']
def validate_title(self, value):
if len(value) < 5:
raise serializers.ValidationError(
"Title must be at least 5 characters long."
)
return value
def validate(self, attrs):
if attrs.get('status') == 'published' and not attrs.get('body'):
raise serializers.ValidationError({
'body': 'Published articles must have a body.'
})
return attrs
def create(self, validated_data):
validated_data['author'] = self.context['request'].user
return super().create(validated_data)# Common serializer field types
class MySerializer(serializers.Serializer):
# Basic
id = serializers.IntegerField(read_only=True)
name = serializers.CharField(max_length=200)
email = serializers.EmailField()
is_active = serializers.BooleanField(default=True)
age = serializers.IntegerField(
min_value=0, max_value=150)
score = serializers.FloatField()
balance = serializers.DecimalField(
max_digits=10, decimal_places=2)
created = serializers.DateTimeField(
format='%Y-%m-%d %H:%M')
# Choice
priority = serializers.ChoiceField(
choices=['low','med','high'])
# Related
category = serializers.PrimaryKeyRelatedField(
queryset=Category.objects.all())
tags = serializers.SlugRelatedField(
many=True, slug_field='name',
queryset=Tag.objects.all())
author_url = serializers.HyperlinkedRelatedField(
view_name='user-detail',
lookup_field='username',
read_only=True)
# Computed
full_name = serializers.SerializerMethodField()
item_count = serializers.IntegerField(
source='items.count', read_only=True)
# File
avatar = serializers.ImageField(
max_length=100,
allow_empty_file=False)
attachment = serializers.FileField(
use_url=True)
# Custom
description = serializers.CharField(
allow_blank=True, required=False,
help_text="Markdown supported")
password = serializers.CharField(
write_only=True,
style={'input_type': 'password'})
def get_full_name(self, obj):
return f"{obj.first_name} {obj.last_name}"# Nested writable serializer (create/update related objects)
class ProfileSerializer(serializers.ModelSerializer):
class Meta:
model = Profile
fields = ['bio', 'avatar']
class UserCreateSerializer(serializers.ModelSerializer):
profile = ProfileSerializer(required=False)
class Meta:
model = User
fields = ['username', 'email', 'password', 'profile']
extra_kwargs = {'password': {'write_only': True}}
def create(self, validated_data):
profile_data = validated_data.pop('profile', None)
password = validated_data.pop('password')
user = User(**validated_data)
user.set_password(password) # hash it!
user.save()
if profile_data:
Profile.objects.create(user=user, **profile_data)
return user
def update(self, instance, validated_data):
profile_data = validated_data.pop('profile', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
if profile_data and hasattr(instance, 'profile'):
for attr, value in profile_data.items():
setattr(instance.profile, attr, value)
instance.profile.save()
return instanceuser.set_password() before saving. Never store raw passwords. DRF serializers give you direct access — use it responsibly.# Function-based views with @api_view
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticatedOrReadOnly
@api_view(['GET', 'POST'])
@permission_classes([IsAuthenticatedOrReadOnly])
def article_list(request):
"""List all articles or create a new one."""
if request.method == 'GET':
articles = Article.objects.select_related('author')\
.prefetch_related('tags').all()
serializer = ArticleSerializer(
articles, many=True,
context={'request': request}
)
return Response(serializer.data)
elif request.method == 'POST':
serializer = ArticleSerializer(
data=request.data,
context={'request': request}
)
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
)@api_view(['GET', 'PUT', 'DELETE'])
@permission_classes([IsAuthenticated])
def article_detail(request, pk):
"""Retrieve, update or delete an article."""
try:
article = Article.objects.get(pk=pk)
except Article.DoesNotExist:
return Response(
{'detail': 'Not found.'},
status=status.HTTP_404_NOT_FOUND
)
if request.method == 'GET':
serializer = ArticleSerializer(
article, context={'request': request}
)
return Response(serializer.data)
elif request.method == 'PUT':
serializer = ArticleSerializer(
article, data=request.data,
context={'request': request}
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
elif request.method == 'DELETE':
article.delete()
return Response(
status=status.HTTP_204_NO_CONTENT
)# Class-based APIView — maximum control
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status, permissions
from django.shortcuts import get_object_or_404
class ArticleAPIView(APIView):
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
throttle_scope = 'articles'
def get(self, request, pk=None):
if pk:
article = get_object_or_404(Article, pk=pk)
serializer = ArticleSerializer(
article, context={'request': request}
)
return Response(serializer.data)
articles = Article.objects.filter(
status='published'
).order_by('-created_at')
serializer = ArticleSerializer(
articles, many=True,
context={'request': request}
)
return Response(serializer.data)
def post(self, request):
serializer = ArticleSerializer(
data=request.data,
context={'request': request}
)
if serializer.is_valid():
serializer.save(author=request.user)
return Response(
serializer.data,
status=status.HTTP_201_CREATED
)
return Response(
serializer.errors,
status=status.HTTP_400_BAD_REQUEST
)
def put(self, request, pk):
article = get_object_or_404(Article, pk=pk)
self.check_object_permissions(request, article)
serializer = ArticleSerializer(
article, data=request.data,
context={'request': request}
)
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):
article = get_object_or_404(Article, pk=pk)
self.check_object_permissions(request, article)
article.delete()
return Response(status=status.HTTP_204_NO_CONTENT)# Generic Views — less boilerplate
from rest_framework import generics, mixins
class ArticleListCreateView(generics.ListCreateAPIView):
queryset = Article.objects.filter(status='published')
serializer_class = ArticleSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = ['status', 'author']
search_fields = ['title', 'body']
ordering_fields = ['created_at', 'title']
ordering = ['-created_at']
def perform_create(self, serializer):
serializer.save(author=self.request.user)
class ArticleDetailView(generics.RetrieveUpdateDestroyAPIView):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
permission_classes = [IsOwnerOrReadOnly]
lookup_field = 'slug' # default is 'pk'
# Mixin composition (uncommon but powerful)
class ArticleListMixin(
mixins.ListModelMixin,
mixins.CreateModelMixin,
generics.GenericAPIView
):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
def get(self, request, *args, **kwargs):
return self.list(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
return self.create(request, *args, **kwargs)| View Type | Best For | Control Level | Lines of Code |
|---|---|---|---|
| @api_view | Quick endpoints, simple logic | Medium | Low |
| APIView | Custom logic, non-CRUD | Full | Medium |
| Generic Views | Standard CRUD operations | Medium | Very Low |
| ModelViewSet | Full CRUD + routing | Medium | Minimal |
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.permissions import IsAdminUser
class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.select_related('author')\
.prefetch_related('tags', 'comments').all()
serializer_class = ArticleSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
filterset_fields = ['status', 'author', 'tags']
search_fields = ['title', 'body']
ordering_fields = ['created_at', 'title', 'status']
lookup_field = 'slug'
def get_serializer_class(self):
if self.action == 'list':
return ArticleListSerializer
if self.action in ('create', 'update', 'partial_update'):
return ArticleWriteSerializer
return ArticleDetailSerializer
def get_permissions(self):
if self.action in ('create', 'update', 'partial_update', 'destroy'):
self.permission_classes = [IsOwnerOrReadOnly]
if self.action == 'stats':
self.permission_classes = [IsAdminUser]
return super().get_permissions()
def perform_create(self, serializer):
serializer.save(author=self.request.user)
def get_queryset(self):
qs = super().get_queryset()
if not self.request.user.is_staff:
qs = qs.filter(status='published')
return qs
# ── Custom action ──
@action(detail=False, methods=['get'], url_path='my-articles')
def mine(self, request):
"""Return current user's articles."""
articles = self.queryset.filter(author=request.user)
page = self.paginate_queryset(articles)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(articles, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'])
def publish(self, request, slug=None):
"""Publish an article."""
article = self.get_object()
article.status = 'published'
article.save(update_fields=['status', 'updated_at'])
serializer = self.get_serializer(article)
return Response(serializer.data)
@action(detail=True, methods=['get'])
def comments(self, request, slug=None):
"""List comments for an article."""
article = self.get_object()
comments = article.comments.select_related('author').all()
serializer = CommentSerializer(comments, many=True)
return Response(serializer.data)
@action(detail=True, methods=['post'], permission_classes=[IsAdminUser])
def feature(self, request, slug=None):
"""Feature an article (admin only)."""
article = self.get_object()
article.is_featured = True
article.save(update_fields=['is_featured'])
return Response({'status': 'featured'})| Action | HTTP Method | URL Pattern | Detail? | Description |
|---|---|---|---|---|
| list | GET | /articles/ | No | List all resources |
| create | POST | /articles/ | No | Create a new resource |
| retrieve | GET | /articles/{slug}/ | Yes | Get single resource |
| update | PUT | /articles/{slug}/ | Yes | Full update |
| partial_update | PATCH | /articles/{slug}/ | Yes | Partial update |
| destroy | DELETE | /articles/{slug}/ | Yes | Delete resource |
| mine (custom) | GET | /articles/my-articles/ | No | User's own articles |
| publish (custom) | POST | /articles/{slug}/publish/ | Yes | Publish an article |
| comments (custom) | GET | /articles/{slug}/comments/ | Yes | List comments |
@action(detail=True) for per-object endpoints (receives pk or lookup_field) and @action(detail=False) for collection endpoints (no primary key).| Mixin | Provides | Methods |
|---|---|---|
| ListModelMixin | list | GET (collection) |
| CreateModelMixin | create | POST |
| RetrieveModelMixin | retrieve | GET (single) |
| UpdateModelMixin | update, partial_update | PUT, PATCH |
| DestroyModelMixin | destroy | DELETE |
# api/urls.py — Standard router setup
from rest_framework.routers import DefaultRouter, SimpleRouter
from .views import ArticleViewSet, CommentViewSet, UserViewSet
router = DefaultRouter()
router.register(r'articles', ArticleViewSet, basename='article')
router.register(r'comments', CommentViewSet, basename='comment')
router.register(r'users', UserViewSet, basename='user')
urlpatterns = router.urls
# In project/urls.py: path('api/v1/', include('api.urls'))# SimpleRouter — NO root API view
simple = SimpleRouter()
simple.register('articles', ArticleViewSet)
# URLs generated:
# GET /articles/ -> list
# POST /articles/ -> create
# GET /articles/{pk}/ -> retrieve
# PUT /articles/{pk}/ -> update
# PATCH /articles/{pk}/ -> partial_update
# DELETE /articles/{pk}/ -> destroy
# DefaultRouter — includes root API view + format suffixes
default = DefaultRouter()
default.register('articles', ArticleViewSet)
# Additional URLs:
# GET /api/ -> root view (auto-generated)
# Accept header / .json suffix supported# Nested routers (requires drf-nested-routers)
from rest_framework_nested import routers
parent_router = DefaultRouter()
parent_router.register('articles', ArticleViewSet)
# Nested: /articles/<article_pk>/comments/
comments_router = routers.NestedDefaultRouter(
parent_router,
r'articles',
lookup='article' # url kwarg name
)
comments_router.register(
r'comments',
CommentViewSet,
basename='article-comments'
)
urlpatterns = parent_router.urls + comments_router.urls# Manual URL wiring for custom views
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register(r'articles', views.ArticleViewSet)
# Wire ViewSet actions manually (override defaults)
article_detail = views.ArticleViewSet.as_view({
'get': 'retrieve',
'put': 'update',
'patch': 'partial_update',
'delete': 'destroy',
})
article_list = views.ArticleViewSet.as_view({
'get': 'list',
'post': 'create',
})
urlpatterns = [
# Manual routes take precedence over router URLs
path('api/v1/articles/latest/', views.latest_articles, name='article-latest'),
path('api/v1/articles/<slug:slug>/', article_detail, name='article-detail'),
path('api/v1/articles/', article_list, name='article-list'),
path('api/v1/', include(router.urls)),
]
# Versioned URLs
urlpatterns += [
path('api/v2/', include([
path('articles/', views.V2ArticleListView.as_view()),
])),
]| Parameter | Type | Description |
|---|---|---|
| prefix | str | URL prefix for all routes in this set |
| viewset | ViewSet | The ViewSet class to route |
| basename | str | Base name for URL reversing (required if no queryset) |
# settings.py — JWT configuration
from datetime import timedelta
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
'UPDATE_LAST_LOGIN': True,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY, # or import from env
'AUTH_HEADER_TYPES': ('Bearer',),
'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
'TOKEN_OBTAIN_SERIALIZER': 'rest_framework_simplejwt.serializers.TokenObtainPairSerializer',
'TOKEN_REFRESH_SERIALIZER': 'rest_framework_simplejwt.serializers.TokenRefreshSerializer',
}
INSTALLED_APPS = [
...
'rest_framework_simplejwt',
'rest_framework_simplejwt.token_blacklist', # for rotation
]# Custom token claims
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from rest_framework_simplejwt.views import TokenObtainPairView
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
@classmethod
def get_token(cls, user):
token = super().get_token(user)
# Add custom claims
token['user_id'] = user.id
token['username'] = user.username
token['email'] = user.email
token['role'] = user.profile.role if hasattr(user, 'profile') else 'user'
token['is_staff'] = user.is_staff
return token
def validate(self, attrs):
data = super().validate(attrs)
# Add extra response data
data['user'] = {
'id': self.user.id,
'username': self.user.username,
'email': self.user.email,
'role': getattr(self.user.profile, 'role', 'user'),
}
return data
class CustomTokenObtainPairView(TokenObtainPairView):
serializer_class = CustomTokenObtainPairSerializer# Custom authentication class
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from django.contrib.auth import get_user_model
User = get_user_model()
class CustomHeaderAuthentication(BaseAuthentication):
"""Authenticate via custom X-API-Key header."""
def authenticate(self, request):
api_key = request.META.get('HTTP_X_API_KEY')
if not api_key:
return None # Move to next auth backend
try:
user = User.objects.get(api_key=api_key, is_active=True)
except User.DoesNotExist:
raise AuthenticationFailed('Invalid API key')
return (user, api_key) # (user, auth_info)# api/urls.py — JWT token endpoints
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
TokenBlacklistView,
)
from .authentication import CustomTokenObtainPairView
urlpatterns = [
...
path('auth/token/', CustomTokenObtainPairView.as_view(),
name='token_obtain_pair'),
path('auth/token/refresh/', TokenRefreshView.as_view(),
name='token_refresh'),
path('auth/token/blacklist/', TokenBlacklistView.as_view(),
name='token_blacklist'),
]from rest_framework import permissions
class HasRole(permissions.BasePermission):
"""Grant access only to users with a specific role."""
role_map = {
'GET': ['admin', 'editor', 'viewer'],
'POST': ['admin', 'editor'],
'PUT': ['admin', 'editor'],
'PATCH': ['admin', 'editor'],
'DELETE': ['admin'],
}
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
role = getattr(request.user.profile, 'role', 'viewer')
allowed_roles = self.role_map.get(request.method, [])
return role in allowed_roles
class IsOwnerOrReadOnly(permissions.BasePermission):
"""
Object-level permission: only the owner can modify.
Safe methods (GET, HEAD, OPTIONS) are allowed for anyone.
"""
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.author == request.user
class IsStaffOrReadOnly(permissions.BasePermission):
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return request.user and request.user.is_staffhas_permission applies.import django_filters
from django_filters import rest_framework as filters
from .models import Article
class ArticleFilter(filters.FilterSet):
# Exact match (dropdown-friendly)
status = filters.CharFilter(field_name='status')
author = filters.NumberFilter(field_name='author__id')
# Range
created_after = filters.DateTimeFilter(
field_name='created_at', lookup_expr='gte'
)
created_before = filters.DateTimeFilter(
field_name='created_at', lookup_expr='lte'
)
min_comments = filters.NumberFilter(
field_name='comments__count', lookup_expr='gte', method=None
)
# Text search with custom method
title_contains = filters.CharFilter(
field_name='title', lookup_expr='icontains'
)
# Boolean
is_featured = filters.BooleanFilter(field_name='is_featured')
# Custom method filter
@django_filters.method
def filter_popular(self, queryset, name, value):
if value:
return queryset.filter(views__gte=1000)
return queryset
popular = filters.BooleanFilter(method='filter_popular')
class Meta:
model = Article
fields = {
'status': ['exact'],
'author': ['exact'],
'created_at': ['gte', 'lte', 'exact'],
'title': ['icontains', 'exact'],
'tags__name': ['exact'],
}
# Usage in ViewSet:
class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
filterset_class = ArticleFilter # OR filterset_fields = ['status', 'author']class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
# Search: ?search=django (searches across these fields)
search_fields = ['title', 'body', 'author__username']
# Prefix modifiers for search:
# ^title -> starts-with
# =title -> exact match
# @title -> full-text search (PostgreSQL)
# title -> contains (default, icontains)
# Ordering: ?ordering=-created_at,title
ordering_fields = ['created_at', 'title', 'status', 'views']
ordering = ['-created_at'] # default
# Filter backends (explicit)
filter_backends = [
DjangoFilterBackend, # filterset_class / filterset_fields
SearchFilter, # search_fields
OrderingFilter, # ordering_fields
]# settings.py — global pagination
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS':
'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
}
# Or configure per-view:
from rest_framework.pagination import (
PageNumberPagination,
LimitOffsetPagination,
CursorPagination,
)
class StandardPagination(PageNumberPagination):
page_size = 20
page_size_query_param = 'page_size'
max_page_size = 100
class ArticlePagination(LimitOffsetPagination):
default_limit = 20
limit_query_param = 'limit'
offset_query_param = 'offset'
max_limit = 100
class CursorArticlePagination(CursorPagination):
page_size = 20
ordering = '-created_at' # MUST be unique, unambiguous
cursor_query_param = 'cursor'
page_size_query_param = 'page_size'
max_page_size = 100| Strategy | Query Params | Best For | Drawbacks |
|---|---|---|---|
| PageNumber | ?page=2&page_size=50 | General use, SEO | Slow on large offsets |
| LimitOffset | ?limit=10&offset=20 | API flexibility | Inconsistent results on inserts |
| Cursor | ?cursor=cD02... | Infinite scroll, large datasets | Cannot jump to page |
select_related() and prefetch_related() to avoid N+1 queries.from rest_framework import serializers
class AuthorSerializer(serializers.ModelSerializer):
class Meta:
model = Author
fields = ['id', 'username', 'display_name', 'avatar']
class ArticleSerializer(serializers.ModelSerializer):
# 1. PrimaryKeyRelatedField — returns just the PK
author_id = serializers.PrimaryKeyRelatedField(
source='author', read_only=True
)
# Output: { "author_id": 42 }
# 2. StringRelatedField — returns __str__
author_str = serializers.StringRelatedField(source='author')
# Output: { "author_str": "johndoe" }
# 3. SlugRelatedField — returns specific field
author_username = serializers.SlugRelatedField(
source='author', slug_field='username', read_only=True
)
# Output: { "author_username": "johndoe" }
# 4. HyperlinkedRelatedField — returns full URL
author_url = serializers.HyperlinkedRelatedField(
source='author', view_name='user-detail',
lookup_field='username', read_only=True
)
# Output: { "author_url": "http://api.example.com/users/johndoe/" }
# 5. HyperlinkedIdentityField — URL to THIS object
url = serializers.HyperlinkedIdentityField(
view_name='article-detail', lookup_field='slug'
)
# Output: { "url": "http://api.example.com/articles/my-post/" }
# 6. Nested Serializer — full embedded object
author = AuthorSerializer(read_only=True)
# Output: { "author": { "id": 42, "username": "johndoe", ... } }
# 7. Many variants (for ForeignKey/ManyToMany reverse)
category_name = serializers.SlugRelatedField(
source='category', slug_field='name', read_only=True
)
tags = serializers.SlugRelatedField(
many=True, slug_field='name', read_only=True
)
comments = CommentSerializer(many=True, read_only=True)
class Meta:
model = Article
fields = '__all__'| Field Type | Output | Use Case | Extra Query? |
|---|---|---|---|
| PrimaryKeyRelatedField | { "author": 42 } | Minimal payload, client stores IDs | No |
| StringRelatedField | { "author": "John Doe" } | Display only, simplest | No |
| SlugRelatedField | { "author": "johndoe" } | Human-readable identifier | No |
| HyperlinkedRelatedField | { "author": "/api/users/42/" } | HATEOAS-compliant API | No |
| HyperlinkedIdentityField | { "url": "/api/articles/5/" } | Self-link in response | No |
| Nested Serializer | { "author": { ... } } | Full embedded data | Yes (use prefetch!) |
from django.db.models import Prefetch, Count, Q
class ArticleViewSet(viewsets.ModelViewSet):
serializer_class = ArticleDetailSerializer
def get_queryset(self):
return Article.objects.annotate(
comment_count=Count('comments'),
like_count=Count('likes'),
).select_related(
'author', # ForeignKey (single object)
'category', # ForeignKey (single object)
).prefetch_related(
# Prefetch with custom queryset (filter, select_related)
Prefetch(
'comments',
queryset=Comment.objects.select_related('author')
.filter(is_approved=True)
.order_by('-created_at')[:50],
to_attr='approved_comments' # store on model
),
'tags', # ManyToMany
).only(
# Defer — only load these columns
'id', 'title', 'slug', 'body',
'status', 'author_id', 'category_id',
'created_at', 'updated_at',
).filter(
status='published'
).order_by('-created_at')
# In serializer, access annotated fields directly:
class ArticleDetailSerializer(serializers.ModelSerializer):
comment_count = serializers.IntegerField(read_only=True)
like_count = serializers.IntegerField(read_only=True)
approved_comments = CommentSerializer(many=True, read_only=True)
class Meta:
model = Article
fields = [
'id', 'title', 'slug', 'body', 'status',
'author', 'category', 'tags',
'comment_count', 'like_count', 'approved_comments',
'created_at', 'updated_at',
]# Django signals for model side effects
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from .models import Article, ArticleStat
@receiver(post_save, sender=Article)
def create_article_stat(sender, instance, created, **kwargs):
if created:
ArticleStat.objects.create(article=instance)
@receiver(post_save, sender=Article)
def update_article_cache(sender, instance, **kwargs):
from django.core.cache import cache
cache.delete(f'article:{instance.slug}')
cache.delete('articles:featured')
# Alternative: override save() (simpler, synchronous)
class Article(models.Model):
...
def save(self, *args, **kwargs):
is_new = self.pk is None
super().save(*args, **kwargs)
if is_new:
ArticleStat.objects.create(article=self)
self.update_search_index() # e.g., Elasticsearchselect_related() for ForeignKey and OneToOne (SQL JOIN). Use prefetch_related() for ManyToMany and reverse ForeignKey (separate query, Python-side JOIN). Use Prefetch() to customize the sub-queryset.from rest_framework.test import APITestCase, APIClient
from django.urls import reverse
from django.contrib.auth import get_user_model
from .models import Article
User = get_user_model()
class ArticleAPITests(APITestCase):
"""Test suite for Article API endpoints."""
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
email='test@example.com',
password='securepass123',
)
self.staff_user = User.objects.create_superuser(
username='admin',
email='admin@example.com',
password='adminpass123',
)
self.article = Article.objects.create(
title='Test Article',
slug='test-article',
body='This is the body.',
status='published',
author=self.user,
)
self.list_url = '/api/v1/articles/'
self.detail_url = f'/api/v1/articles/{self.article.slug}/'
def _authenticate(self, user=None):
"""Helper: authenticate a user."""
user = user or self.user
self.client.force_authenticate(user=user)
# ── List tests ──
def test_list_articles_unauthenticated(self):
"""Anonymous users can read published articles."""
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, 200)
self.assertGreaterEqual(len(response.data['results']), 1)
def test_list_articles_authenticated(self):
"""Authenticated users get the same list."""
self._authenticate()
response = self.client.get(self.list_url)
self.assertEqual(response.status_code, 200)
self.assertIn('results', response.data)
# ── Create tests ──
def test_create_article_authenticated(self):
"""Authenticated users can create articles."""
self._authenticate()
payload = {
'title': 'New Article',
'body': 'Some content here.',
'status': 'draft',
}
response = self.client.post(self.list_url, payload, format='json')
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data['title'], 'New Article')
self.assertEqual(response.data['author'], self.user.id)
def test_create_article_unauthenticated(self):
"""Anonymous users cannot create articles."""
payload = {'title': 'Nope', 'body': 'Blocked.'}
response = self.client.post(self.list_url, payload)
self.assertEqual(response.status_code, 401)
def test_create_article_validation_error(self):
"""Short title should fail validation."""
self._authenticate()
payload = {'title': 'Hi', 'body': 'Too short title'}
response = self.client.post(self.list_url, payload)
self.assertEqual(response.status_code, 400)
self.assertIn('title', response.data)
# ── Detail tests ──
def test_retrieve_article(self):
"""Anyone can retrieve a published article."""
response = self.client.get(self.detail_url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['slug'], 'test-article')
def test_update_article_owner(self):
"""Owner can update their article."""
self._authenticate()
payload = {'title': 'Updated Title'}
response = self.client.patch(self.detail_url, payload)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['title'], 'Updated Title')
def test_update_article_non_owner(self):
"""Non-owner cannot update someone else's article."""
other = User.objects.create_user(
username='other', password='pass123'
)
self._authenticate(user=other)
payload = {'title': 'Hacked!'}
response = self.client.patch(self.detail_url, payload)
self.assertEqual(response.status_code, 403)
def test_delete_article_owner(self):
"""Owner can delete their article."""
self._authenticate()
response = self.client.delete(self.detail_url)
self.assertEqual(response.status_code, 204)# pytest + factory_boy setup
# conftest.py
import pytest
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model
from factory import Factory, Faker, SubFactory, post_generation
from factory.django import DjangoModelFactory
User = get_user_model()
# ── Factories ──
class UserFactory(DjangoModelFactory):
class Meta:
model = User
username = Faker('user_name')
email = Faker('email')
password = Faker('password', length=12)
@post_generation
def set_password(obj, create, extracted, **kwargs):
if not create:
return
obj.set_password(extracted or 'defaultpass123')
obj.save()
class ArticleFactory(DjangoModelFactory):
class Meta:
model = Article
title = Faker('sentence', nb_words=6)
slug = Faker('slug')
body = Faker('paragraph')
status = 'published'
author = SubFactory(UserFactory)
# ── Fixtures ──
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def user(db):
return UserFactory()
@pytest.fixture
def auth_client(api_client, user):
api_client.force_authenticate(user=user)
return api_client
@pytest.fixture
def article(user):
return ArticleFactory(author=user)
@pytest.fixture(autouse=True)
def enable_db_access(db):
pass # pytest-django handles this# tests/test_article_pytest.py
import pytest
from rest_framework import status
from django.urls import reverse
@pytest.mark.django_db
class TestArticleAPI:
def test_list_articles(self, api_client, article):
url = reverse('article-list')
response = api_client.get(url)
assert response.status_code == status.HTTP_200_OK
assert len(response.data['results']) >= 1
def test_create_article(self, auth_client):
url = reverse('article-list')
payload = {
'title': 'Pytest Article',
'body': 'Created via pytest.',
'status': 'draft',
}
response = auth_client.post(url, payload, format='json')
assert response.status_code == status.HTTP_201_CREATED
assert response.data['title'] == 'Pytest Article'
def test_unauth_create_fails(self, api_client):
url = reverse('article-list')
response = api_client.post(url, {
'title': 'Nope', 'body': 'Blocked.'
})
assert response.status_code == status.HTTP_401_UNAUTHORIZED
@pytest.mark.parametrize('status_val, expected_code', [
('published', status.HTTP_200_OK),
('draft', status.HTTP_404_NOT_FOUND),
])
def test_retrieve_by_status(
self, api_client, article_factory, status_val, expected_code
):
article = article_factory(status=status_val)
url = reverse('article-detail', kwargs={'slug': article.slug})
response = api_client.get(url)
assert response.status_code == expected_codefactory_boy is the industry standard for DRF testing. Use @pytest.mark.django_db and parametrize tests for better coverage. Keep setUp() fixtures in conftest.py.Serializer is a blank slate — you define every field explicitly. Great for non-model data (API responses, aggregation results, webhook payloads) or when you need full control over input/output fields.
ModelSerializer auto-generates fields from a Django model. It automatically creates fields for model fields, implements create() and update(), and uses model validators. Use it whenever your data maps directly to a model.
Key difference: ModelSerializer introspects the model's fields and adds default validators (e.g., max_length from CharField). With plain Serializer you'd have to duplicate these. Always prefer ModelSerializer for model-backed data.
N+1 problem: Serializing a list of N articles, each accessing article.author.username, fires 1 query for articles + N queries for authors.
Solutions:
1. select_related('author') — SQL JOIN for ForeignKey/OneToOne (single additional query).
2. prefetch_related('tags') — Separate query for ManyToMany/reverse FK, joined in Python.
3. Prefetch('comments', queryset=Comment.objects.select_related('author')) — nested optimization.
4. Use only() / defer() to limit columns loaded from DB.
Debugging: Use django-debug-toolbar or connection.queries to identify N+1 patterns. Set DEBUG = True locally.
has_permission(self, request, view) — Called on every request before any object is loaded. Applies to list, create, and collection-level actions. Return True/False.
has_object_permission(self, request, view, obj) — Called only for retrieve, update, partial_update, destroy — i.e., when a single object is accessed. The obj is already fetched.
Flow: has_permission runs first. If it returns False, the request is denied immediately. Only if it returns True does DRF proceed to fetch the object and call has_object_permission.
Common pattern: Use has_permission for view-level checks (is authenticated? is staff?) and has_object_permission for ownership checks (obj.user == request.user). The built-in IsAuthenticated only implements has_permission.
DRF supports multiple versioning schemes via DEFAULT_VERSIONING_CLASS:
URLPathVersioning — /api/v1/articles/. Most common, RESTful, easy to understand. Use request.version in views.
NamespaceVersioning — Same URL, different Django URL namespaces. Good for internal APIs.
AcceptHeaderVersioning — Version in Accept header: Accept: application/json; version=1.0. Clean URLs but less discoverable.
HostNameVersioning — v1.api.example.com. Requires DNS config.
QueryParameterVersioning — /api/articles/?version=1. Simple but pollutes query strings.
Best practice: URLPathVersioning is the industry standard. Use get_serializer_class() or separate ViewSets per version.
When a request arrives, DRF iterates through DEFAULT_AUTHENTICATION_CLASSES in order. Each class's authenticate(request) is called:
1. Return (user, auth) tuple — authentication successful.
2. Return None — this class couldn't authenticate; try next backend.
3. Raise AuthenticationFailed — immediately reject with 401.
After auth, DRF sets request.user and request.auth. If all backends return None, request.user is set to AnonymousUser.
Then permissions check runs. If any permission denies, a 403 is returned. Finally, throttling checks rate limits (returns 429 if exceeded).
@action adds custom endpoints to a ViewSet. Use it when the action logically belongs to the resource (e.g., /articles/{id}/publish/, /users/{id}/deactivate/).
Advantages: Shares authentication, permissions, throttling, and serializer logic with the parent ViewSet. Auto-generates URLs via router. Keeps related endpoints together.
Separate view is better when: the endpoint doesn't map to a specific model instance, needs completely different auth/permission logic, or serves an unrelated use case (e.g., /dashboard/stats/).
Rule of thumb: If the URL naturally nests under a resource and shares most of its context, use @action. Otherwise, use a separate view.
DRF returns standard error format: { "field_name": ["error message"] } for validation errors. Customize via:
1. Serializer validation — raise serializers.ValidationError({"field": "msg"}) or return errors dict in validate().
2. Custom exception handler — override EXCEPTION_HANDLER in settings to format all 4xx/5xx responses consistently.
3. Custom renderer — override JSONRenderer.render() to wrap all responses in a standard envelope (e.g., {"success": false, "errors": [...]}).
4. raise_api_exception() — for non-field errors: raise exceptions.APIException(detail="Custom message", status_code=429).
Best practice: Use a custom exception handler + renderer for consistent error format across the entire API.
Database: Use select_related() and prefetch_related() everywhere. Add database indexes on filter/order columns. Use only()/defer() to avoid loading unneeded columns.
Caching: Cache expensive querysets with Django's cache framework or Redis. Use cache_page decorator for read-heavy endpoints. Invalidate on write with signals.
Pagination: Never return unbounded lists. Use CursorPagination for large datasets (no COUNT queries).
Serialization: Avoid nested serializers for lists (use PrimaryKeyRelatedField instead). Use values() or values_list() for flat data.
Async: Use Django Channels or switch to async views (ASGI) for I/O-bound work. Consider djangorestframework-camel-case for consistent naming.
Throttling: Implement rate limiting. Use UserRateThrottle and AnonRateThrottle. Add custom scopes per endpoint.
Infrastructure: Put DRF behind nginx/gunicorn with multiple workers. Use connection pooling (e.g., CONN_MAX_AGE). Consider read replicas.
Request Object
HTTP Status Codes
ViewSet Helpers
Response Helpers