Django REST Framework ViewSets — Deep Dive
How ViewSets dispatch requests
Understanding ViewSet internals starts with as_view(). Unlike regular Django views, ViewSet’s as_view() accepts a mapping of HTTP methods to action names:
# What the router generates internally
article_list = ArticleViewSet.as_view({
'get': 'list',
'post': 'create'
})
article_detail = ArticleViewSet.as_view({
'get': 'retrieve',
'put': 'update',
'patch': 'partial_update',
'delete': 'destroy'
})
When a request arrives, the ViewSet’s dispatch() method maps the HTTP method to the action name, sets self.action, then delegates to the appropriate method. This indirection is why self.action is available throughout the request lifecycle — useful for per-action serializer selection, permissions, and queryset filtering.
# Simplified from DRF source
def dispatch(self, request, *args, **kwargs):
self.action = self.action_map.get(request.method.lower())
handler = getattr(self, self.action)
return handler(request, *args, **kwargs)
Per-action serializer selection
A common production pattern: different serializers for different actions.
class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.select_related('author')
def get_serializer_class(self):
if self.action == 'list':
return ArticleListSerializer # Lightweight: id, title, date
if self.action == 'create':
return ArticleCreateSerializer # Accepts: title, content, tags
return ArticleDetailSerializer # Full: all fields + nested relations
def get_queryset(self):
qs = super().get_queryset()
if self.action == 'list':
return qs.only('id', 'title', 'published_at', 'author_id')
return qs.prefetch_related('tags', 'comments')
This pattern keeps list responses fast (fewer fields, no joins) while providing full detail on individual retrieval. The queryset optimization in get_queryset() matches the serializer fields to avoid fetching unused data.
Nested resources with drf-nested-routers
For URLs like /authors/5/articles/, use drf-nested-routers:
from rest_framework_nested import routers
router = routers.DefaultRouter()
router.register(r'authors', AuthorViewSet)
authors_router = routers.NestedDefaultRouter(router, r'authors', lookup='author')
authors_router.register(r'articles', AuthorArticleViewSet, basename='author-articles')
# Generates:
# /authors/
# /authors/{pk}/
# /authors/{author_pk}/articles/
# /authors/{author_pk}/articles/{pk}/
The nested ViewSet filters by the parent:
class AuthorArticleViewSet(viewsets.ModelViewSet):
serializer_class = ArticleSerializer
def get_queryset(self):
return Article.objects.filter(
author_id=self.kwargs['author_pk']
)
def perform_create(self, serializer):
serializer.save(author_id=self.kwargs['author_pk'])
Custom action patterns
Beyond simple CRUD, @action supports rich patterns:
Bulk operations
@action(detail=False, methods=['post'])
def bulk_publish(self, request):
ids = request.data.get('ids', [])
if not isinstance(ids, list) or len(ids) > 100:
return Response(
{'error': 'Provide a list of up to 100 IDs'},
status=400
)
updated = Article.objects.filter(
id__in=ids,
author=request.user,
published_at__isnull=True
).update(published_at=timezone.now())
return Response({'published': updated})
State transitions
@action(detail=True, methods=['post'], url_path='transition/(?P<new_status>\\w+)')
def transition(self, request, pk=None, new_status=None):
article = self.get_object()
valid_transitions = {
'draft': ['review'],
'review': ['published', 'draft'],
'published': ['archived'],
}
allowed = valid_transitions.get(article.status, [])
if new_status not in allowed:
return Response(
{'error': f'Cannot transition from {article.status} to {new_status}'},
status=400
)
article.status = new_status
article.save(update_fields=['status'])
return Response(self.get_serializer(article).data)
Throttling and rate limiting per action
from rest_framework.throttling import UserRateThrottle
class BurstRateThrottle(UserRateThrottle):
rate = '60/minute'
class SustainedRateThrottle(UserRateThrottle):
rate = '1000/day'
class ArticleViewSet(viewsets.ModelViewSet):
def get_throttles(self):
if self.action == 'create':
return [BurstRateThrottle()]
if self.action in ['update', 'partial_update']:
return [SustainedRateThrottle()]
return []
Per-action throttling prevents abuse of write endpoints while keeping reads unrestricted.
Optimizing queryset performance
ViewSets make it easy to introduce N+1 queries. The serializer might access related fields that the queryset didn’t prefetch. A systematic approach:
class ArticleViewSet(viewsets.ModelViewSet):
def get_queryset(self):
qs = Article.objects.all()
if self.action == 'list':
return qs.select_related('author').only(
'id', 'title', 'published_at',
'author__id', 'author__name'
)
elif self.action == 'retrieve':
return qs.select_related(
'author', 'author__profile'
).prefetch_related(
Prefetch(
'comments',
queryset=Comment.objects.select_related('user').order_by('-created')[:20]
),
'tags'
)
return qs
Pair with Django Debug Toolbar or query logging middleware to verify query counts per endpoint.
Pagination control
DRF provides pagination classes that integrate with ViewSets:
from rest_framework.pagination import CursorPagination
class ArticleCursorPagination(CursorPagination):
page_size = 20
ordering = '-published_at'
cursor_query_param = 'cursor'
class ArticleViewSet(viewsets.ModelViewSet):
pagination_class = ArticleCursorPagination
Cursor-based pagination is O(1) regardless of dataset size, unlike offset pagination which degrades as offset increases. It’s ideal for feeds and timelines but doesn’t support jumping to arbitrary pages.
For endpoints that need both cursor and traditional pagination:
def get_paginator(self):
if self.action == 'list':
return ArticleCursorPagination()
return None # No pagination for detail views
Caching strategies
ViewSet list endpoints often benefit from caching:
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page
class ArticleViewSet(viewsets.ReadOnlyModelViewSet):
@method_decorator(cache_page(60 * 15)) # 15 minutes
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
For per-user caching, use vary_on_headers:
from django.views.decorators.vary import vary_on_headers
@method_decorator(cache_page(60 * 5))
@method_decorator(vary_on_headers('Authorization'))
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
Testing ViewSets
DRF’s APIClient tests ViewSets through the router-generated URLs:
from rest_framework.test import APITestCase, APIClient
class TestArticleViewSet(APITestCase):
def setUp(self):
self.client = APIClient()
self.user = UserFactory()
self.client.force_authenticate(user=self.user)
def test_list_returns_published_only(self):
ArticleFactory(published_at=timezone.now()) # visible
ArticleFactory(published_at=None) # draft
response = self.client.get('/api/articles/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data['results']), 1)
def test_create_requires_authentication(self):
self.client.force_authenticate(user=None)
response = self.client.post('/api/articles/', {
'title': 'Test', 'content': 'Body'
})
self.assertEqual(response.status_code, 401)
def test_custom_action_publish(self):
article = ArticleFactory(author=self.user, published_at=None)
response = self.client.post(f'/api/articles/{article.id}/publish/')
self.assertEqual(response.status_code, 200)
article.refresh_from_db()
self.assertIsNotNone(article.published_at)
def test_bulk_publish_limit(self):
ids = list(range(1, 200)) # Over the 100 limit
response = self.client.post('/api/articles/bulk_publish/', {
'ids': ids
}, format='json')
self.assertEqual(response.status_code, 400)
ViewSet vs APIView decision framework
| Factor | ViewSet | APIView |
|---|---|---|
| Standard CRUD | Excellent | Verbose |
| Non-model endpoints | Awkward | Natural |
| URL generation | Automatic via router | Manual |
| Action-specific logic | get_serializer_class(), get_permissions() | Method override |
| Learning curve | Higher (more magic) | Lower (explicit) |
| Customization ceiling | High but constrained | Unlimited |
Use ViewSets for resource APIs where models map to endpoints. Use APIView for RPC-style endpoints, aggregation views, or anything that doesn’t fit the resource pattern.
The one thing to remember: ViewSets are an abstraction over DRF’s generic views that trades explicitness for convention — master the dispatch lifecycle, per-action overrides, and queryset optimization to build APIs that are both concise and performant.
See Also
- Python Django Admin Get an intuitive feel for Django Admin so Python behavior stops feeling unpredictable.
- Python Django Basics Get an intuitive feel for Django Basics so Python behavior stops feeling unpredictable.
- Python Django Celery Integration Why your Django app needs a helper to handle slow jobs in the background.
- Python Django Channels Websockets How Django can send real-time updates to your browser without you refreshing the page.
- Python Django Custom Management Commands How to teach Django new tricks by creating your own command-line shortcuts.