Django REST Framework: Patterns & Best Practices
Section 6 of 17

Django REST Framework Views: APIView vs ViewSets

DRF gives you a whole hierarchy of view classes, and understanding where each one sits in that hierarchy — and when to reach for which — is one of the most important skills in DRF development.

graph TD
    A[View - Django's base] --> B[APIView - DRF's base view]
    B --> C[GenericAPIView - adds queryset/serializer]
    C --> D[ListAPIView]
    C --> E[CreateAPIView]
    C --> F[RetrieveAPIView]
    C --> G[UpdateAPIView]
    C --> H[DestroyAPIView]
    C --> I[ListCreateAPIView]
    C --> J[RetrieveUpdateDestroyAPIView]
    B --> K[ViewSetMixin + GenericAPIView = GenericViewSet]
    K --> L[ReadOnlyModelViewSet]
    K --> M[ModelViewSet]

APIView: The Foundation

APIView is DRF's base view class. It's like Django's View class but with DRF superpowers — authentication checking, permission checking, content negotiation, and response rendering are all handled automatically.

Think of APIView as the foundation of a building. You wouldn't live in just a foundation — you'd build rooms on top of it. But everything above rests on it, and understanding how it works explains why the floors above behave the way they do.

How APIView Processes Requests

When a request arrives at an APIView, DRF runs through a predictable sequence of steps before your handler method is ever called:

  1. Authentication — DRF checks the Authorization header, cookies, or other configured auth methods and populates request.user and request.auth
  2. Permission checks — DRF calls has_permission() on each permission class. If any returns False, the request is rejected immediately
  3. Content negotiation — DRF inspects the Accept header to determine which renderer should format the response
  4. Your method handler — Your get(), post(), put(), etc. method is invoked with the fully-prepared request object
  5. Response rendering — DRF serializes your response data and applies the negotiated content type

APIView in Action

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticatedOrReadOnly

class ArticleListView(APIView):
    permission_classes = [IsAuthenticatedOrReadOnly]
    
    def get(self, request):
        """List all articles. Everyone can read."""
        articles = Article.objects.all()
        serializer = ArticleSerializer(articles, many=True, context={'request': request})
        return Response(serializer.data)
    
    def post(self, request):
        """Create a new article. Only authenticated users."""
        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)

When to Use APIView

Use APIView when:

  • You're building an endpoint that doesn't map cleanly to CRUD operations (e.g., a login endpoint, a password reset handler, a webhook receiver)
  • You need very fine-grained control over the logic that generic views would hide
  • The endpoint doesn't have a clear model/queryset backing it, or has complex custom business logic
  • You're implementing a non-standard API pattern (e.g., batch operations, data imports)

GenericAPIView and Mixins

GenericAPIView extends APIView with attributes and methods for working with querysets and serializers. By itself, it provides:

  • A queryset attribute to specify which objects you're operating on
  • A serializer_class attribute to specify which serializer handles serialization/deserialization
  • Convenience methods like get_queryset() and get_serializer()

The real power comes from combining GenericAPIView with mixins. Each mixin provides exactly one method that implements a single action:

  • ListModelMixin provides list() for GET collection requests
  • CreateModelMixin provides create() for POST requests
  • RetrieveModelMixin provides retrieve() for GET single-object requests
  • UpdateModelMixin provides update() for PUT requests
  • DestroyModelMixin provides destroy() for DELETE requests

DRF provides pre-built combinations like ListCreateAPIView and RetrieveUpdateDestroyAPIView that save you from manually composing mixins.

Generic View Example

from rest_framework import generics
from rest_framework.permissions import IsAuthenticatedOrReadOnly

class ArticleListCreateView(generics.ListCreateAPIView):
    queryset = Article.objects.select_related('author').all()
    serializer_class = ArticleSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]
    
    def perform_create(self, serializer):
        serializer.save(author=self.request.user)

This single view class handles both GET /articles/ (list) and POST /articles/ (create), and you only override perform_create() to add custom save logic.

The perform_* Hook Pattern

The perform_create() hook is a beautiful DRF pattern. Instead of overriding the entire post() method and duplicating all the validation logic, you just override the moment where serializer.save() is called:

def perform_create(self, serializer):
    """Called after the serializer is valid, before save() is called."""
    serializer.save(
        author=self.request.user,
        ip_address=get_client_ip(self.request),
    )

There are similar hooks: perform_update(serializer) and perform_destroy(obj). These keep the validation/deserialization logic (which DRF handles) separate from your business logic (which you control).

ViewSets: The Power Move

A ViewSet groups related actions together into a single class. ModelViewSet is the most powerful built-in ViewSet:

from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticatedOrReadOnly

class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.select_related('author').prefetch_related('tags').all()
    serializer_class = ArticleSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]
    
    def get_queryset(self):
        """Override to filter based on query params or current user."""
        qs = super().get_queryset()
        if self.action == 'list':
            qs = qs.filter(status='published')
        return qs
    
    def perform_create(self, serializer):
        serializer.save(author=self.request.user)

Notice self.action — it tells you which operation is being performed (list, create, retrieve, update, partial_update, destroy). You can conditionally apply different logic per action.

Routing a ViewSet

from rest_framework.routers import DefaultRouter
from django.urls import path, include

router = DefaultRouter()
router.register('articles', ArticleViewSet, basename='article')

urlpatterns = [
    path('api/', include(router.urls)),
]

The router generates all six standard endpoints automatically from a single registration.

Custom Actions with @action

from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework import status
from django.utils import timezone

class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    
    @action(detail=True, methods=['post'])
    def publish(self, request, pk=None):
        """POST /articles/{id}/publish/ — publish a draft article."""
        article = self.get_object()  # Always use get_object() — it runs permission checks!
        if article.status != 'draft':
            return Response(
                {'error': 'Only draft articles can be published.'},
                status=status.HTTP_400_BAD_REQUEST
            )
        article.status = 'published'
        article.published_at = timezone.now()
        article.save()
        return Response({'status': 'Article published.'})
    
    @action(detail=False, methods=['get'])
    def featured(self, request):
        """GET /articles/featured/ — list featured articles."""
        featured = Article.objects.filter(is_featured=True)
        serializer = self.get_serializer(featured, many=True)
        return Response(serializer.data)

The get_object() Security Principle

A critical security principle: in custom actions, always retrieve objects using self.get_object(), not self.queryset.get(pk=pk) or Article.objects.get(pk=pk).

Why? Because get_object() automatically calls check_object_permissions(), which runs has_object_permission() on all your permission classes. If you bypass get_object(), you bypass your entire object-level security layer.

When to Use What: A Decision Guide

graph TD
    A["Is this endpoint CRUD on a model?"] --> |Yes| B["Use ModelViewSet"]
    A --> |No| C{"Is it custom logic for<br/>a model object?"}
    C --> |Yes| D["Use APIView"]
    C --> |No| E["Use APIView"]
    B --> F["Apply @action decorator<br/>for custom endpoints"]
    G["Read-only access only?"] --> |Yes| H["Use ReadOnlyModelViewSet"]
    G --> |No| B
    I["Only allow specific<br/>CRUD operations?"] --> |Yes| J["Use GenericAPIView<br/>+ custom mixins"]
    I --> |No| K["Use ModelViewSet<br/>or ListCreateAPIView, etc."]
  • ModelViewSet: Standard CRUD on a model. Your default choice for 80% of your endpoints.
  • ReadOnlyModelViewSet: Only list() and retrieve() allowed. Perfect for reference data that users can read but not modify.
  • GenericAPIView + mixins: When you want CRUD-like behavior with specific action combinations.
  • APIView: Non-CRUD endpoints. Login, logout, password reset, webhook receivers, batch operations.