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:
- Function-based views (
@api_view) — you write every line. Maximum control, maximum boilerplate. APIView— a class with.get()/.post()methods. Object-oriented, still explicit.- Generic class-based views (
ListCreateAPIView,RetrieveUpdateDestroyAPIView, …) — set aquerysetand aserializer_classand DRF wires up the CRUD logic for you. - 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_routeand@list_routedecorators 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 attributes — permission_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. Setqueryset+serializer_classand you're done. Replaces the two generic-view classes above with one.ReadOnlyModelViewSet—list+retrieveonly (GET). For reference data and reports.ViewSet/GenericViewSet— the bases when you want to define actions yourself.GenericViewSetaddsget_queryset()/get_object()and works with mixins; plainViewSetgives 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)
DefaultRoutervsSimpleRouter:DefaultRouteradds a browsable API root view and supports optional.json-style format suffixes;SimpleRouteromits 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):
...For deeper coverage of authorization, see user-level and object-level permissions in DRF and token-based authentication in DRF.
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.