~/TechPurAI
~/tutorials/django-rest-framework/viewsets
intermediate·part 9 of 22·2 min read

ViewSets: collapsing a resource's views into one class

Updated Aug 16, 2026Python · Django

TaskListView and TaskDetailView from part 8 both declare the same queryset and serializer_class — two classes describing one resource. ViewSet merges that into one class for the whole resource, list and detail together.

ModelViewSet

python
# tasks/views.py
from rest_framework.viewsets import ModelViewSet
from .models import Task
from .serializers import TaskSerializer

class TaskViewSet(ModelViewSet):
    queryset = Task.objects.all()
    serializer_class = TaskSerializer

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)

One class, queryset and serializer_class declared exactly once, and ModelViewSet provides every operation both part 8 generic views did combined: list, create, retrieve, update, partial update, and destroy. perform_create() works identically to the generic view version from part 8 — ViewSets share the same hook methods generic views do, since ModelViewSet is actually built from the same generic view mixins underneath.

Where the URLs come from

A ViewSet isn't wired up with path() the way every view so far has been — it doesn't map cleanly to one URL pattern, since list and detail need different paths (/tasks/ vs. /tasks/1/) from the same class. Manually, it looks like this:

python
task_list = TaskViewSet.as_view({"get": "list", "post": "create"})
task_detail = TaskViewSet.as_view({"get": "retrieve", "put": "update", "delete": "destroy"})

urlpatterns = [
    path("tasks/", task_list, name="task-list"),
    path("tasks/<int:pk>/", task_detail, name="task-detail"),
]

.as_view({...}) here maps each HTTP method to one of the six standard actions (list, create, retrieve, update, partial_update, destroy) that ModelViewSet implements. This works, but it's exactly the repetition a ViewSet was supposed to remove — writing it by hand for every viewset in a real project defeats the point. Part 10 replaces this whole block with one line.

Custom actions beyond CRUD

python
from rest_framework.decorators import action
from rest_framework.response import Response

class TaskViewSet(ModelViewSet):
    queryset = Task.objects.all()
    serializer_class = TaskSerializer

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)

    @action(detail=True, methods=["post"])
    def mark_complete(self, request, pk=None):
        task = self.get_object()
        task.completed = True
        task.save()
        return Response(self.get_serializer(task).data)

@action is what a plain generic view can't do cleanly — an operation that isn't one of the six standard ones. detail=True means it operates on a single object (self.get_object() fetches it using the same lookup the built-in retrieve action already uses) and needs a pk in the URL; detail=False would add a list-level action instead, like /tasks/completed/ with no ID. Router-generated URLs (part 10) pick this up automatically as /tasks/1/mark_complete/.

ViewSet or generic view?

A ViewSet is the right default once a resource needs the full standard set — list, create, retrieve, update, delete — since it removes the duplicated queryset/serializer_class part 8 still had. A single-purpose endpoint that's genuinely just one operation, with no natural "resource" shape, often stays clearer as a standalone APIView or @api_view instead.

Next: Router — generating the URL patterns this part had to write by hand, automatically, from the ViewSet itself.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous8. Generic views: ListCreateAPIView and RetrieveUpdateDestroyAPIViewnext →10. Routers: automatic URL generation for ViewSets