ViewSets are a powerful abstraction in Django REST Framework, but their magic can obscure the fundamental HTTP request/response cycle that APIView exposes directly.
Let’s see how this plays out. Imagine a simple Book model:
# models.py
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.CharField(max_length=100)
published_date = models.DateField()
def __str__(self):
return self.title
And a basic APIView to list books:
# views.py
from rest_framework.views import APIView
from rest_framework.response import Response
from .models import Book
from .serializers import BookSerializer
class BookListView(APIView):
def get(self, request, format=None):
books = Book.objects.all()
serializer = BookSerializer(books, many=True)
return Response(serializer.data)
When a GET request hits /api/books/, DRF’s router maps it to BookListView.as_view(). This as_view() method is a class method that returns a callable. When that callable is invoked, it creates an instance of BookListView and calls the appropriate HTTP method handler (get in this case). The get method then fetches data, serializes it, and returns a Response object.
Now, let’s look at the equivalent using a ViewSet:
# views.py (continued)
from rest_framework.viewsets import ViewSet
from rest_framework.response import Response
from .models import Book
from .serializers import BookSerializer
class BookViewSet(ViewSet):
def list(self, request):
queryset = Book.objects.all()
serializer = BookSerializer(queryset, many=True)
return Response(serializer.data)
The magic here is that ViewSet doesn’t directly handle HTTP methods. Instead, it defines actions like list, create, retrieve, update, partial_update, and destroy. A ViewSet needs to be explicitly wired up to a router (like DefaultRouter) which then inspects the ViewSet, identifies its available actions, and maps them to the correct HTTP methods and URL patterns. For example, a DefaultRouter would automatically create a URL pattern for /api/books/ that maps to the list action of BookViewSet, and a GET request to that URL would trigger the list method.
The core problem ViewSet solves is reducing boilerplate. If you have a resource that naturally supports the full CRUD operations (Create, Read, Update, Delete), writing separate APIView classes for each operation becomes repetitive. A ModelViewSet or ReadOnlyModelViewSet takes this further by automatically providing implementations for all standard CRUD actions based on a queryset and serializer_class defined on the view.
Consider the common task of retrieving a single Book instance. With APIView, you’d write a new class or add a get method to an existing one that accepts an ID in the URL:
# urls.py
from django.urls import path
from .views import BookDetailView # Assuming you have this
urlpatterns = [
path('api/books/<int:pk>/', BookDetailView.as_view(), name='book-detail'),
]
# views.py (for APIView)
class BookDetailView(APIView):
def get(self, request, pk, format=None):
try:
book = Book.objects.get(pk=pk)
serializer = BookSerializer(book)
return Response(serializer.data)
except Book.DoesNotExist:
return Response(status=404)
With ViewSet and a router, this is handled by convention. If you have a BookViewSet with a retrieve action:
# views.py (for ViewSet)
from rest_framework.viewsets import ViewSet
from rest_framework.response import Response
from rest_framework import status
from .models import Book
from .serializers import BookSerializer
class BookViewSet(ViewSet):
def list(self, request):
queryset = Book.objects.all()
serializer = BookSerializer(queryset, many=True)
return Response(serializer.data)
def retrieve(self, request, pk=None):
queryset = Book.objects.all()
try:
book = queryset.get(pk=pk)
except Book.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
serializer = BookSerializer(book)
return Response(serializer.data)
And your urls.py uses a router:
# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import BookViewSet
router = DefaultRouter()
router.register(r'books', BookViewSet, basename='book')
urlpatterns = [
path('api/', include(router.urls)),
]
A GET request to /api/books/1/ will be routed by DRF’s router to the retrieve action of BookViewSet, automatically passing pk=1. The ViewSet’s retrieve method then fetches the book.
ViewSets are essentially a collection of related APIViews. The router is the component that bridges the URL patterns to the specific actions within a ViewSet. When you use a ModelViewSet, DRF is doing even more heavy lifting: it’s automatically generating the queryset based on your serializer_class’s model, and providing default implementations for list, create, retrieve, update, etc., that interact directly with the ORM.
The most surprising thing about ViewSets is that they are not themselves tied to HTTP methods; they are tied to actions. The router is what performs the mapping from HTTP verbs and URL structures to these actions. This is why a ViewSet can define an action named latest and, if routed correctly, make it accessible via a GET request to a specific URL pattern, even though ViewSet itself doesn’t have a get method.
The key lever you control with ViewSets is how you define your actions and how you configure your router. For simple CRUD on a single model, ReadOnlyModelViewSet or ModelViewSet is often the most efficient. For more complex logic that doesn’t neatly fit the CRUD paradigm, a custom ViewSet or even a plain APIView might be more appropriate.
The next thing you’ll want to understand is how to customize the behavior of ModelViewSets beyond just defining queryset and serializer_class, particularly through methods like get_queryset() and perform_create().