DRF Views Explained: Function, Class-Based, Generic & ViewSets

Blog / Django · October 14, 2023 · Updated June 10, 2026 · 13 min read
DRF Views Explained: Function, Class-Based, Generic & ViewSets

Django REST Framework (DRF) gives you four ways to write an API view, and they sit on a clear spectrum from most control / most code to least code / most convention:

  1. Function-based views (@api_view) — you write every line. Maximum control, maximum boilerplate.
  2. APIView — a class with .get() / .post() methods. Object-oriented, still explicit.
  3. Generic class-based views (ListCreateAPIView, RetrieveUpdateDestroyAPIView, …) — set a queryset and a serializer_class and DRF wires up the CRUD logic for you.
  4. ViewSets + routers (ModelViewSet) — one class for a whole resource, with URLs generated automatically by a router.

Which should you use? As a rule of thumb: reach for a ModelViewSet + DefaultRouter when you are exposing standard CRUD on a model (this is most REST APIs). Drop down to generic views when one endpoint needs different behaviour from the rest of the resource. Use APIView for an endpoint that isn't a clean model-CRUD mapping (a custom action, an aggregation, an external integration). Use a function-based view for a one-off, throwaway, or highly procedural endpoint where a class adds no value.

This guide walks the same simple resource — a Task model — implemented in all four styles, with modern Django 5.x and DRF 3.15 / 3.16 / 3.17 code and correct path() / router routing. It is written for developers building production APIs, drawn from our 12+ years and 50+ projects shipping Django and DRF backends. If you are new to DRF, start with our introduction to API development with Django REST Framework and understanding Django serializers with examples.

The shared model and serializer

Every example below exposes the same resource, so you can see exactly how much code each view style saves. First, the model and serializer they all share.

# tasks/models.py
from django.db import models


class Task(models.Model):
    title = models.CharField(max_length=200)
    completed = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.title


# tasks/serializers.py
from rest_framework import serializers

from .models import Task


class TaskSerializer(serializers.ModelSerializer):
    class Meta:
        model = Task
        fields = ["id", "title", "completed", "created_at"]
        read_only_fields = ["id", "created_at"]

Function-based views (@api_view)

A function-based view is a plain Python function decorated with @api_view. The decorator turns the function into a DRF view: it ensures the request is a DRF Request (so request.data works for JSON, form, and multipart bodies alike), restricts the function to the listed HTTP methods, and lets the function return a DRF Response that the renderer turns into JSON.

You do everything yourself — fetch the object, run the serializer, choose the status code. That is the point: maximum control, maximum boilerplate.

Per-view configuration with decorators

Function-based views configure cross-cutting concerns with extra decorators, applied after @api_view:

  • @permission_classes([...]) — who can call this endpoint.
  • @authentication_classes([...]) — how the caller is identified.
  • @throttle_classes([...]) — rate limiting.
  • @renderer_classes([...]) / @parser_classes([...]) — response/request media types.

Note: The old @detail_route and @list_route decorators were deprecated in DRF 3.8 and removed in 3.9. Use @action(detail=True/False) on a ViewSet instead (shown later). If you are reading an older tutorial that uses them, it predates 2018.

# tasks/views_fbv.py
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.response import Response

from .models import Task
from .serializers import TaskSerializer


@api_view(["GET", "POST"])
@permission_classes([IsAuthenticatedOrReadOnly])
def task_list(request):
    if request.method == "GET":
        tasks = Task.objects.all()
        serializer = TaskSerializer(tasks, many=True)
        return Response(serializer.data)

    # POST
    serializer = TaskSerializer(data=request.data)  # request.data, not request.POST
    serializer.is_valid(raise_exception=True)
    serializer.save()
    return Response(serializer.data, status=status.HTTP_201_CREATED)


@api_view(["GET", "PUT", "PATCH", "DELETE"])
@permission_classes([IsAuthenticatedOrReadOnly])
def task_detail(request, pk):
    try:
        task = Task.objects.get(pk=pk)
    except Task.DoesNotExist:
        return Response(status=status.HTTP_404_NOT_FOUND)

    if request.method == "GET":
        return Response(TaskSerializer(task).data)

    if request.method in ("PUT", "PATCH"):
        partial = request.method == "PATCH"
        serializer = TaskSerializer(task, data=request.data, partial=partial)
        serializer.is_valid(raise_exception=True)
        serializer.save()
        return Response(serializer.data)

    # DELETE
    task.delete()
    return Response(status=status.HTTP_204_NO_CONTENT)
# tasks/urls.py  (function-based views)
from django.urls import path

from . import views_fbv

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

Class-based views with APIView

APIView is the base class for all DRF class-based views. Instead of branching on request.method, you write a .get(), .post(), .put(), .patch(), or .delete() method, and DRF dispatches the incoming request to the matching handler. Anything not defined returns 405 Method Not Allowed automatically.

Configuration moves from decorators to class attributespermission_classes, authentication_classes, throttle_classes, renderer_classes, and so on. This is cleaner than a stack of decorators once a view has more than one method, and it lets you share behaviour through inheritance.

APIView still leaves the CRUD logic to you. Use it when an endpoint is genuinely custom — a report, a webhook receiver, an action that touches several models — rather than a straight model-CRUD mapping.

# tasks/views_apiview.py
from django.http import Http404
from rest_framework import status
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from rest_framework.views import APIView

from .models import Task
from .serializers import TaskSerializer


class TaskList(APIView):
    permission_classes = [IsAuthenticatedOrReadOnly]

    def get(self, request):
        serializer = TaskSerializer(Task.objects.all(), many=True)
        return Response(serializer.data)

    def post(self, request):
        serializer = TaskSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        serializer.save()
        return Response(serializer.data, status=status.HTTP_201_CREATED)


class TaskDetail(APIView):
    permission_classes = [IsAuthenticatedOrReadOnly]

    def get_object(self, pk):
        try:
            return Task.objects.get(pk=pk)
        except Task.DoesNotExist:
            raise Http404

    def get(self, request, pk):
        return Response(TaskSerializer(self.get_object(pk)).data)

    def patch(self, request, pk):
        serializer = TaskSerializer(self.get_object(pk), data=request.data, partial=True)
        serializer.is_valid(raise_exception=True)
        serializer.save()
        return Response(serializer.data)

    def delete(self, request, pk):
        self.get_object(pk).delete()
        return Response(status=status.HTTP_204_NO_CONTENT)
# tasks/urls.py  (APIView)
from django.urls import path

from .views_apiview import TaskDetail, TaskList

urlpatterns = [
    path("tasks/", TaskList.as_view(), name="task-list"),
    path("tasks/<int:pk>/", TaskDetail.as_view(), name="task-detail"),
]

Mixins and GenericAPIView

Between bare APIView and the ready-made generic views sits GenericAPIView plus a set of mixins. GenericAPIView adds the machinery the CRUD pattern needs — a queryset, a serializer_class, get_object(), get_serializer(), pagination, and filtering. The mixins supply the actual actions:

  • ListModelMixin.list()
  • CreateModelMixin.create()
  • RetrieveModelMixin.retrieve()
  • UpdateModelMixin.update()
  • DestroyModelMixin.destroy()

You combine the mixins you need and map HTTP methods to mixin actions. In practice you rarely write this by hand — the concrete generic views below already do it — but understanding the layering explains why a one-line ListCreateAPIView works.

# tasks/views_mixins.py
from rest_framework import generics, mixins

from .models import Task
from .serializers import TaskSerializer


class TaskList(mixins.ListModelMixin,
               mixins.CreateModelMixin,
               generics.GenericAPIView):
    queryset = Task.objects.all()
    serializer_class = TaskSerializer

    def get(self, request, *args, **kwargs):
        return self.list(request, *args, **kwargs)

    def post(self, request, *args, **kwargs):
        return self.create(request, *args, **kwargs)

Concrete generic class-based views

DRF pre-combines GenericAPIView with the right mixins into ready-to-use classes. Set two attributes — queryset and serializer_class — and you have a working endpoint. This is the sweet spot for standard CRUD when you still want one URL per class.

The full set:

Generic view HTTP methods Mixins combined
ListAPIView GET (collection) List
CreateAPIView POST Create
RetrieveAPIView GET (single) Retrieve
UpdateAPIView PUT, PATCH Update
DestroyAPIView DELETE Destroy
ListCreateAPIView GET, POST List + Create
RetrieveUpdateAPIView GET, PUT, PATCH Retrieve + Update
RetrieveDestroyAPIView GET, DELETE Retrieve + Destroy
RetrieveUpdateDestroyAPIView GET, PUT, PATCH, DELETE Retrieve + Update + Destroy

For a full task resource you need just two classes: ListCreateAPIView for the collection and RetrieveUpdateDestroyAPIView for a single item.

# tasks/views_generic.py
from rest_framework import generics
from rest_framework.permissions import IsAuthenticatedOrReadOnly

from .models import Task
from .serializers import TaskSerializer


class TaskListCreate(generics.ListCreateAPIView):
    queryset = Task.objects.all()
    serializer_class = TaskSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]


class TaskDetail(generics.RetrieveUpdateDestroyAPIView):
    queryset = Task.objects.all()
    serializer_class = TaskSerializer
    permission_classes = [IsAuthenticatedOrReadOnly]
# tasks/urls.py  (generic views)
from django.urls import path

from .views_generic import TaskDetail, TaskListCreate

urlpatterns = [
    path("tasks/", TaskListCreate.as_view(), name="task-list"),
    path("tasks/<int:pk>/", TaskDetail.as_view(), name="task-detail"),
]

Customizing a generic view

The reason generic views scale to real projects is that every step is an overridable hook. The three you reach for most often:

get_queryset() — dynamic, request-aware querysets

Override this when the rows a user can see depend on the request — filter by the logged-in user, a tenant, or query params. Use it instead of the static queryset attribute when the set isn't fixed.

perform_create() / perform_update() — inject data at save time

Override these to set fields the client should not send — the owner, a timestamp, a tenant ID — by passing extra kwargs to serializer.save().

get_serializer_class() — different serializer per situation

Override this to use a lightweight serializer for list views and a detailed one for retrieve, or a write serializer for input and a read serializer for output.

# tasks/views_generic.py  (customized)
from rest_framework import generics
from rest_framework.permissions import IsAuthenticated

from .models import Task
from .serializers import TaskListSerializer, TaskSerializer


class TaskListCreate(generics.ListCreateAPIView):
    serializer_class = TaskSerializer
    permission_classes = [IsAuthenticated]

    def get_queryset(self):
        # Only the requesting user's tasks; supports ?completed=true
        qs = Task.objects.filter(owner=self.request.user)
        completed = self.request.query_params.get("completed")
        if completed is not None:
            qs = qs.filter(completed=completed.lower() == "true")
        return qs

    def get_serializer_class(self):
        # Slim serializer for the list, full serializer for writes
        if self.request.method == "GET":
            return TaskListSerializer
        return TaskSerializer

    def perform_create(self, serializer):
        # Owner comes from the session, never from the request body
        serializer.save(owner=self.request.user)

ViewSets and routers

A ViewSet collects the logic for a whole resource — list, create, retrieve, update, partial-update, destroy — into a single class, and a router generates all the URLs for it. This is the least code and the strongest convention.

A ViewSet doesn't define .get() / .post() handlers. Instead it has actions: list, create, retrieve, update, partial_update, destroy. The router maps HTTP methods to those actions for you (GET /tasks/list, POST /tasks/create, GET /tasks/1/retrieve, and so on).

The three you'll use:

  • ModelViewSet — full CRUD for a model. Set queryset + serializer_class and you're done. Replaces the two generic-view classes above with one.
  • ReadOnlyModelViewSetlist + retrieve only (GET). For reference data and reports.
  • ViewSet / GenericViewSet — the bases when you want to define actions yourself. GenericViewSet adds get_queryset() / get_object() and works with mixins; plain ViewSet gives you nothing but the action dispatch.

All the customization hooks from generic views — get_queryset(), perform_create(), get_serializer_class() — work identically on a ModelViewSet, because it inherits from the same generic machinery.

Custom actions with @action

Need a non-CRUD endpoint on the resource, like POST /tasks/1/complete/? Add an @action method — this is the modern replacement for the removed @detail_route / @list_route.

# tasks/viewsets.py
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

from .models import Task
from .serializers import TaskSerializer


class TaskViewSet(viewsets.ModelViewSet):
    serializer_class = TaskSerializer
    permission_classes = [IsAuthenticated]

    def get_queryset(self):
        return Task.objects.filter(owner=self.request.user)

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

    @action(detail=True, methods=["post"])
    def complete(self, request, pk=None):
        # Mapped to POST /tasks/{pk}/complete/ automatically
        task = self.get_object()
        task.completed = True
        task.save(update_fields=["completed"])
        return Response(TaskSerializer(task).data)
# project/urls.py  (router)
from django.urls import include, path
from rest_framework.routers import DefaultRouter

from tasks.viewsets import TaskViewSet

router = DefaultRouter()
router.register(r"tasks", TaskViewSet, basename="task")
# basename is required when get_queryset() replaces a static queryset.

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

# The router generates, among others:
#   GET    /api/tasks/                -> list
#   POST   /api/tasks/                -> create
#   GET    /api/tasks/{pk}/           -> retrieve
#   PUT    /api/tasks/{pk}/           -> update
#   PATCH  /api/tasks/{pk}/           -> partial_update
#   DELETE /api/tasks/{pk}/           -> destroy
#   POST   /api/tasks/{pk}/complete/  -> complete (custom @action)

DefaultRouter vs SimpleRouter: DefaultRouter adds a browsable API root view and supports optional .json-style format suffixes; SimpleRouter omits the root view. Both generate the same per-resource URLs. For a deeper look, see understanding routers in Django REST Framework.

Comparison: which view style, and when

Function view (@api_view) APIView Generic views ViewSet + router
Code amount Most High Low Lowest
Control / explicitness Highest High Medium Lowest (convention)
CRUD wiring Manual Manual Built-in (per class) Built-in (whole resource)
URL configuration path() per view path() per class path() per class Auto-generated by router
Config style Decorators Class attributes Class attributes Class attributes
Best for One-off / procedural endpoints Custom, non-CRUD endpoints Standard CRUD, one URL per class Standard CRUD across a whole resource

Decision shortcut

  • Standard model CRUD across a whole resource?ModelViewSet + DefaultRouter. This is most REST APIs.
  • Standard CRUD but you want explicit URLs, or only some endpoints? → generic views (ListCreateAPIView, RetrieveUpdateDestroyAPIView).
  • An endpoint that isn't clean model CRUD (aggregation, integration, webhook)? → APIView.
  • A throwaway, highly procedural, or single-method endpoint? → function-based view.

Mixing styles in one project is normal and encouraged: a ViewSet for the main resources and an APIView for that one weird endpoint is a healthy combination, not a smell.

Hooking up permissions and authentication

Every style above supports the same authentication and permission machinery — only the syntax differs (decorators for function views, class attributes for everything else). Set project-wide defaults in settings.py and override per view where needed.

# settings.py  (project-wide defaults)
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
        "rest_framework.authentication.TokenAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}

# Override on a single view (class attribute) ...
class TaskViewSet(viewsets.ModelViewSet):
    permission_classes = [IsAuthenticatedOrReadOnly]

# ... or on a function-based view (decorator)
@api_view(["GET"])
@permission_classes([AllowAny])
def public_stats(request):
    ...

Frequently Asked Questions

Function-based vs class-based views in DRF — which should I use?

Use class-based views for almost all production endpoints. Generic class-based views and ViewSets eliminate the repetitive CRUD code that function-based views force you to write by hand, and class attributes (permission_classes, serializer_class) read more cleanly than stacked decorators. Reach for a function-based view only for a genuinely one-off, single-method, or highly procedural endpoint where a class adds ceremony without benefit. Both run on the same DRF request/response cycle, so it isn't about performance — it's about how much boilerplate you want to maintain.

What is a ViewSet and when should I use one?

A ViewSet is a single class that holds all the logic for a resource — list, create, retrieve, update, partial_update, and destroy — and is wired to URLs by a router instead of manual path() entries. Use a ModelViewSet whenever you're exposing standard CRUD on a model, which covers most REST APIs. It's the least code and keeps a resource's behaviour in one place, which makes large projects far easier to maintain.

What's the difference between APIView and generic views?

APIView is the base class: you write .get(), .post(), etc., and implement all the CRUD logic yourself. Generic views (ListCreateAPIView, RetrieveUpdateDestroyAPIView, …) inherit from GenericAPIView and bundle mixins that already implement list/create/retrieve/update/destroy — you just set queryset and serializer_class. Use APIView for endpoints that aren't a clean model-CRUD mapping; use generic views when they are.

How do routers work in DRF?

You create a router (usually DefaultRouter), call router.register(r"tasks", TaskViewSet), and include router.urls in your URLconf. The router inspects the ViewSet and auto-generates the URL patterns, mapping HTTP methods to ViewSet actions — GET /tasks/ to list, POST /tasks/ to create, GET /tasks/{pk}/ to retrieve, and so on. Custom @action methods get their own routes too. Pass basename= when your ViewSet uses get_queryset() instead of a static queryset attribute.

How do I customize a generic view's queryset?

Override get_queryset() instead of setting the static queryset attribute. This lets the returned rows depend on the request — filter by self.request.user, a tenant, or self.request.query_params. Because get_queryset() runs per request, it's the correct place for any dynamic or security-sensitive filtering (for example, only returning records the current user owns).

Are function-based views still supported in modern DRF (3.15/3.16)?

Yes. @api_view is fully supported in DRF 3.15, 3.16, and 3.17 and isn't going anywhere. What was removed is the old @detail_route / @list_route pair (gone since DRF 3.9) — use @action(detail=True/False) on a ViewSet for custom routes. Also note modern patterns: read the request body with request.data (not request.POST), and use serializer.is_valid(raise_exception=True) for clean error responses.

Do I have to pick just one view style for my whole project?

No, and you shouldn't try to. A typical DRF project uses ModelViewSets for its main resources, a generic view or two where a resource needs special handling, and an APIView (or function-based view) for the odd endpoint that isn't model CRUD — a health check, a webhook receiver, an aggregated report. Mixing styles is idiomatic; match each endpoint to the simplest style that fits.

Summary

DRF's view styles are a ladder, not a set of rivals. Function-based views give you total control at the cost of boilerplate; APIView organises that control into methods; generic views remove the CRUD boilerplate; and ViewSets plus routers remove the URL boilerplate too. Start at the convenient end — ModelViewSet + DefaultRouter — and drop down a rung only when an endpoint needs it.

At MicroPyramid we've built and maintained Django REST APIs for startups and enterprises for over 12 years across 50+ projects, and this is exactly how we structure them: conventional ViewSets for the bulk of the surface, with surgical drops to generic and APIView where the domain demands it. If you're designing or scaling a Django API, our Django development services and broader Python development services teams can help — and if you're weighing async Python, see our FastAPI development services. For more DRF depth, read how to develop a RESTful web service in Django using DRF and best practices for Django and Django REST Framework.

Share this article