Filter concepts by levelShowing all levels.

Django · Section 46

DRF Views

Level
advanced
Read
28 min
Concepts
3

APIView, generic views, and mixins form a spectrum of how much CRUD behavior is generated versus hand-written — APIView gives only DRF's request/response/auth plumbing, a mixin (ListModelMixin, CreateModelMixin, etc.) gives one specific reusable behavior still needing manual wiring, and a full generic view (ListCreateAPIView, RetrieveUpdateDestroyAPIView) gives the common combination already wired. ViewSets take a genuinely different organizing principle — one class per RESOURCE, with actions named list/create/retrieve/update/partial_update/destroy rather than by HTTP method — and a Router auto-generates the matching URLs from a registered ViewSet by convention, the one piece of DRF with no generic-view equivalent. @action adds a non-CRUD endpoint to a ViewSet, always routed through self.get_object() to stay consistent with the resource's own authorization. Customization happens through the narrowest correct hook — get_queryset()/get_serializer_class()/get_serializer_context() for per-request row/shape/context decisions, and perform_create()/perform_update()/perform_destroy() for a side effect or a server-controlled field (like setting owner from request.user) around the actual save — never by reimplementing list()/create() wholesale, and never by accepting a server-controlled field as writable serializer input.

What is true here

  1. APIView → a mixin → a generic view is a spectrum from fully hand-written to fully pre-wired CRUD behavior.
  2. A ViewSet organizes by resource, not URL — list/create/retrieve/update/partial_update/destroy actions, mapped to URLs by a Router.
  3. ModelViewSet = GenericViewSet + every model mixin; @action needs self.get_object() to keep authorization consistent.
  4. get_queryset()/get_serializer_class()/get_serializer_context() are the per-request customization hooks generic views and ViewSets already call internally.
  5. perform_create()/update()/destroy() are where a server-controlled field belongs — never accepted as writable serializer input, which is a real authorization bypass.

What you will be able to do

  • Choose the right point on the APIView-to-generic-view spectrum for a given endpoint's shape
  • Build a ViewSet with custom @action endpoints correctly, and register it with a Router
  • Customize view behavior via the narrowest correct hook method rather than reimplementing a mixin's method
  • Set server-controlled fields safely via perform_create()/update(), never as writable client input

APIView, generic views, and mixins

The spectrum from hand-written request handling to fully pre-wired CRUD.

APIView, generic views, and mixins

coreintermediate

APIView is DRF's equivalent of Django's View — the base class every other DRF class-based view builds on, adding DRF-specific request/response handling (request.data instead of request.POST, content negotiation, DRF's own authentication/permission/throttle checks) but leaving get()/post()/etc. entirely up to you, same as plain Django. Generic views (ListAPIView, RetrieveAPIView, CreateAPIView, etc.) are pre-built APIView subclasses that already implement the common CRUD patterns via mixins — each generic view is really just a specific mixin combination plus a matching HTTP-method handler. Mixins (ListModelMixin, CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin) are the actual reusable behavior — a .list()/.create()/.retrieve()/.update()/.destroy() method each — that generic views compose together, and that a custom view can mix in selectively for a non-standard combination.

Think of it as

DRF's view hierarchy exists to let a developer choose exactly how much is generated versus hand-written, on a spectrum: APIView gives the least (DRF's request/response/auth plumbing, nothing else) — appropriate when a view's logic genuinely doesn't map to CRUD-on-one-model. A mixin (ListModelMixin, etc.) gives one specific, reusable behavior as a method (.list(), .create()) that still needs a class combining it with APIView and wiring get()/post() to call it — appropriate for a custom combination the pre-built generic views don't already cover. A generic view (ListCreateAPIView, RetrieveUpdateDestroyAPIView) gives the FULL combination already wired up — appropriate for the common case where a view really is just "list/create/retrieve/update/delete this model," which is most CRUD API endpoints. This layered design is why understanding mixins matters even when generic views are used 90% of the time: the moment a real endpoint needs something slightly non-standard (list and create, but a custom retrieve), knowing that ListCreateAPIView is just ListModelMixin + CreateModelMixin + GenericAPIView, wired with matching get()/post() methods, is what makes it obvious how to build the same shape by hand instead of fighting a generic view that doesn't quite fit.

python
class MyView(generics.ListCreateAPIView):
    queryset = Model.objects.all()
    serializer_class = MySerializer

What we're doing: Build a custom view (list + a non-standard bulk-archive action) by combining mixins manually, since no single generic view covers this exact combination.

articles/views.pypython
class ArticleListAndBulkArchive(mixins.ListModelMixin, generics.GenericAPIView):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer

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

    def post(self, request, *args, **kwargs):
        updated = self.get_queryset().filter(id__in=request.data["ids"]).update(status="archived")
        return Response({"archived": updated})
1
Only ListModelMixin is combined with GenericAPIView — there's no CreateModelMixin here, since POST does something entirely custom instead of creating an Article.
5
get() explicitly calls self.list() — a mixin's method is never called automatically; the handler method (get/post/etc.) still has to invoke it, same as a full generic view does internally.

Why this works: This endpoint's shape (list is standard, but POST means "bulk archive" rather than "create one Article") doesn't match any pre-built generic view — composing ListModelMixin with a hand-written post() gets exactly the needed behavior without duplicating list()'s pagination/filtering/serialization logic from scratch.

Overriding a mixin's top-level method (e.g. list()) to add a small tweak, instead of the more specific hook method it already calls

Wrong

python
class ArticleList(generics.ListAPIView):
    def list(self, request, *args, **kwargs):
        # copy-pasted the ENTIRE ListModelMixin.list() implementation
        # just to change the queryset filtering
        queryset = self.filter_queryset(self.get_queryset().filter(status="published"))
        ...

Better

python
class ArticleList(generics.ListAPIView):
    serializer_class = ArticleSerializer

    def get_queryset(self):
        return Article.objects.filter(status="published")

What you see: A large amount of DRF's own pagination/filtering/serialization logic gets copy-pasted and reimplemented just to change one thing — and that copy silently drifts out of sync with DRF's actual list() behavior on the next DRF version upgrade, since it's no longer calling the real implementation at all.

Why: list() (and create(), retrieve(), etc.) are deliberately implemented in terms of smaller, overridable hook methods — get_queryset(), get_serializer(), get_object(), filter_queryset() — specifically so a narrow customization (which queryset, which serializer) doesn't require reimplementing the whole method. Overriding the narrowest hook that actually needs to change is the documented, low-risk way to customize a generic view.

How much DRF generates, least to most

APIView

request/response/auth plumbing only — every handler hand-written

A mixin + GenericAPIView

one behavior (.list(), .create()) — handlers still wired manually

A generic view (ListAPIView, ...)

the full common combination already wired

  1. APIView — request/response/auth plumbing only — every handler hand-written
  2. A mixin + GenericAPIView — one behavior (.list(), .create()) — handlers still wired manually
  3. A generic view (ListAPIView, ...) — the full common combination already wired

The view spectrum, least to most generated

The view spectrum, least to most generated
BaseProvides
APIViewDRF request/response/auth plumbing only — every handler method hand-written
A mixin + GenericAPIViewone specific behavior (.list(), .create(), ...) — handler methods still wired manually
A generic view (ListAPIView, etc.)the full, common combination already wired — no manual handler methods needed

Together

python
class ArticleListCreate(generics.ListCreateAPIView):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer

class ArticleDetail(generics.RetrieveUpdateDestroyAPIView):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer

Remember: APIView gives DRF's request/response/auth plumbing with every handler hand-written; a mixin gives one reusable behavior (.list()/.create()/etc.) still needing manual wiring; a generic view gives the full common combination pre-wired. Customize a generic view via its narrower hook methods (get_queryset(), get_serializer(), get_object()), not by overriding list()/create() wholesale. Always use request.data, never request.POST, on a DRF view.

See also: viewsets and routers · customizing generic view behavior · drf architecture and modelserializer

Advertisement

ViewSets and routers

Organizing by resource instead of URL, auto-generated URLs, and custom @action endpoints.

ViewSet, GenericViewSet, ModelViewSet, routers, and @action

coreintermediate

A ViewSet groups related views for ONE resource into a single class, with methods named by ACTION (list/create/retrieve/update/partial_update/destroy) rather than by HTTP method — a genuinely different organizing principle from APIView's get()/post(). GenericViewSet combines that action-based structure with GenericAPIView's queryset/serializer_class machinery, but implements no actions itself; ModelViewSet adds every model mixin (list/create/retrieve/update/destroy) on top, becoming the ViewSet equivalent of combining ListCreateAPIView and RetrieveUpdateDestroyAPIView into ONE class. A Router (DefaultRouter, SimpleRouter) inspects a registered ViewSet's actions and auto-generates the matching URL patterns (list/create → /articles/, retrieve/update/destroy → /articles/<pk>/) — the one piece of DRF with no APIView/generic-view equivalent at all, since those still need urls.py written by hand. @action adds an extra, non-CRUD endpoint to a ViewSet (e.g. POST /articles/5/publish/), with detail=True/False controlling whether it's nested under a specific object's URL or the collection's.

Think of it as

The jump from APIView/generic views to ViewSets is a genuine shift in organizing principle, not just more convenience: generic views organize code by URL (one class per endpoint — a list-create view, a separate retrieve-update-destroy view), while a ViewSet organizes code by RESOURCE (one class for everything "Article"-related, with actions distinguishing what's being done). This matters because a resource's full CRUD surface is naturally ONE cohesive unit of related behavior sharing the same queryset/serializer/permissions — splitting it across two separate view classes (as generic views do) duplicates that shared configuration, while a ViewSet states it once. Routers exist specifically because ViewSets' action-based methods don't map to urls.py's normal path()-per-view-per-URL shape on their own — a Router is the piece that knows the CONVENTIONAL mapping (list/create actions → the collection URL, retrieve/update/destroy → the detail URL with a pk) and generates it automatically, which is precisely the piece of boilerplate ViewSets are designed to let you skip. @action exists because real resources often need MORE than pure CRUD — "publish this article," "reset this user's password" — and forces those extra operations to stay organized under the same ViewSet (and get correctly routed by the same Router) rather than becoming an unrelated, separately-wired view elsewhere.

python
class MyViewSet(viewsets.ModelViewSet):
    queryset = Model.objects.all()
    serializer_class = MySerializer

    @action(detail=True, methods=["post"])
    def custom_action(self, request, pk=None):
        ...

What we're doing: A ModelViewSet with a custom @action for publishing an article, registered with a Router.

articles/views.py + urls.pypython
class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer

    @action(detail=True, methods=["post"])
    def publish(self, request, pk=None):
        article = self.get_object()
        article.status = "published"
        article.save()
        return Response(ArticleSerializer(article).data)

# urls.py
router = DefaultRouter()
router.register("articles", ArticleViewSet, basename="article")
urlpatterns = [path("api/", include(router.urls))]
5
detail=True nests this under /articles/<pk>/publish/ — the Router picks up @action-decorated methods automatically once the ViewSet is registered, no separate URL entry needed.
7
self.get_object() reuses the same object-fetching logic (including permission checks) every other detail action already uses — not a raw Article.objects.get(pk=pk).

Why this works: get_object() (rather than a raw queryset lookup) ensures the custom action goes through the same get_queryset() filtering and has_object_permission() checks as retrieve()/update()/destroy() — bypassing it would silently skip authorization checks the rest of the ViewSet enforces consistently.

Fetching the object directly inside a custom @action instead of using self.get_object()

Wrong

python
@action(detail=True, methods=["post"])
def publish(self, request, pk=None):
    article = Article.objects.get(pk=pk)   # bypasses get_queryset() filtering AND permission checks
    article.status = "published"
    article.save()
    return Response(ArticleSerializer(article).data)

Better

python
@action(detail=True, methods=["post"])
def publish(self, request, pk=None):
    article = self.get_object()   # goes through get_queryset() + has_object_permission()
    article.status = "published"
    article.save()
    return Response(ArticleSerializer(article).data)

What you see: A user blocked from viewing/editing a specific article via the ViewSet's normal retrieve()/update() (because of an object-level permission check, or a get_queryset() filter scoping to their own articles) can still publish an article they shouldn't have access to at all, through this one custom action that skipped the shared checks.

Why: self.get_object() is where DRF's generic view machinery centralizes get_queryset() filtering AND has_object_permission() enforcement — a raw Model.objects.get(pk=pk) call re-implements only the lookup, silently dropping both of those checks. Every action on a ViewSet, including custom @action methods, should route object access through get_object() specifically to keep authorization consistent across the whole resource.

A Router maps HTTP method + URL to a ViewSet action
GET → list
GET → retrieve
POST → create
PUT/PATCH → update
DELETE → destroy
  • GET → list: collection URL (/articles/), read
  • GET → retrieve: detail URL (/articles/<pk>/), read
  • POST → create: collection URL (/articles/), write
  • PUT/PATCH → update: detail URL (/articles/<pk>/), write
  • DELETE → destroy: detail URL (/articles/<pk>/), write

ViewSet action → URL, as a Router maps it

ViewSet action → URL, as a Router maps it
HTTP method + URLViewSet action
GET /articles/list
POST /articles/create
GET /articles/<pk>/retrieve
PUT / PATCH /articles/<pk>/update / partial_update
DELETE /articles/<pk>/destroy

Together

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

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

Remember: A ViewSet organizes code by RESOURCE (one class, actions named list/create/retrieve/update/partial_update/destroy), not by URL — a genuine shift from generic views' per-URL organization. ModelViewSet = GenericViewSet + every model mixin. A Router auto-generates URLs from a registered ViewSet's actions by convention; basename must be explicit when queryset isn't a class attribute. @action adds a non-CRUD endpoint — always fetch the object via self.get_object(), never a raw queryset lookup, to keep authorization consistent.

See also: apiview generic views and mixins · customizing generic view behavior · object level and resource level authorization

Advertisement

The customization hook methods

get_queryset(), get_serializer_class/context(), and the perform_*() save/delete hooks.

get_queryset(), get_serializer_class/context(), and perform_*()

coreadvanced

get_queryset() overrides the class-level queryset attribute for per-request logic (filtering to request.user, a URL kwarg) — every generic view/mixin method calls this instead of reading self.queryset directly. get_serializer_class() similarly overrides serializer_class for per-request logic (a different serializer for list vs. detail, or based on request.method) — get_serializer() (note: no "_class") is the method that actually INSTANTIATES it, calling get_serializer_class() and get_serializer_context() internally. get_serializer_context() extends the default context ({"request", "view", "format"}) with anything extra a serializer needs. perform_create()/perform_update()/perform_destroy() are the hooks CreateModelMixin/UpdateModelMixin/DestroyModelMixin call to actually save/delete — overriding one of these (rather than create()/update()/destroy() themselves) is the documented way to inject additional logic (setting a field from request.user, an audit log entry) around the save, without re-implementing response formatting.

Think of it as

Every one of these hook methods exists at the exact point where DRF's generic machinery needs something REQUEST-SPECIFIC that a class attribute can't express — get_queryset() over a plain queryset attribute because "which rows" often depends on who's asking; get_serializer_class() over serializer_class because "which shape" can depend on the action or method; get_serializer_context() because a serializer sometimes needs more than the request/view/format DRF provides by default. perform_create()/update()/destroy() follow a slightly different but related logic: CreateModelMixin.create() already handles the FULL response cycle (validate, call perform_create(), build the 201 response with serialized data and a Location header) — splitting perform_create() out as a separate, overridable hook means a customization (attaching request.user as the owner) can inject itself into just the "actually save" step without having to reimplement everything create() does around it. This is the same design principle as list()/create() themselves being built from smaller hooks (seen in the sibling APIView/mixins concept) — DRF consistently prefers exposing the NARROWEST correct override point over requiring a full method reimplementation for a small, common customization.

python
def get_queryset(self):
    return super().get_queryset().filter(...)

def perform_create(self, serializer):
    serializer.save(extra_field=...)

What we're doing: A ViewSet using every hook together: per-user filtering, a lighter list serializer, extra context, and setting the owner on create.

articles/views.pypython
class ArticleViewSet(viewsets.ModelViewSet):
    def get_queryset(self):
        return Article.objects.filter(owner=self.request.user)

    def get_serializer_class(self):
        if self.action == "list":
            return ArticleSummarySerializer
        return ArticleDetailSerializer

    def get_serializer_context(self):
        context = super().get_serializer_context()
        context["include_drafts"] = self.request.query_params.get("drafts") == "1"
        return context

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)
5
self.action (a ViewSet-specific attribute, set by the Router/dispatch before the handler runs) distinguishes list from retrieve/create/update — not available on a plain APIView, which is why this pattern is ViewSet-specific.
11
super().get_serializer_context() is called FIRST — omitting it would silently drop the default request/view/format keys every serializer expects to find.
16
perform_create(serializer) calls serializer.save(owner=...) — the owner is never something the client can set via input, since it isn't part of the serializer's own fields at all.

Why this works: Setting owner via perform_create() rather than accepting it as a serializer input field means a client can never spoof another user's ownership by including "owner": 5 in the request body — the field simply isn't writable from input, only set server-side.

Accepting an "owner" field as writable serializer input instead of setting it in perform_create()

Wrong

python
class ArticleSerializer(serializers.ModelSerializer):
    class Meta:
        model = Article
        fields = ["id", "title", "owner"]   # owner is writable — client-controlled!

Better

python
class ArticleSerializer(serializers.ModelSerializer):
    class Meta:
        model = Article
        fields = ["id", "title"]   # owner NOT in fields at all

# in the view:
def perform_create(self, serializer):
    serializer.save(owner=self.request.user)

What you see: Any authenticated user can create an Article "owned" by a different user simply by including {"owner": <someone else's id>} in the request body — a real authorization bypass, since ownership was meant to reflect who actually made the request, not an arbitrary client-supplied value.

Why: A field left in Meta.fields is writable input unless explicitly marked read_only — "owner" should never be something the client decides at all, which is exactly the case perform_create()'s serializer.save(owner=self.request.user) is designed for: setting a field server-side, entirely independent of whatever the client submitted, rather than trusting client input for something that must reflect actual request state.

Which hook, called by what

get_queryset()

called by list(), retrieve(), update(), destroy() — per-request row filtering

get_serializer_class()

called by get_serializer() — per-action/method serializer choice

get_serializer_context()

called by get_serializer() — extra context beyond request/view/format

perform_create/update/destroy()

called by create()/update()/destroy() — a side effect or server-set field around the save

  1. get_queryset() — called by list(), retrieve(), update(), destroy() — per-request row filtering
  2. get_serializer_class() — called by get_serializer() — per-action/method serializer choice
  3. get_serializer_context() — called by get_serializer() — extra context beyond request/view/format
  4. perform_create/update/destroy() — called by create()/update()/destroy() — a side effect or server-set field around the save

The hook methods, and what calls each

The hook methods, and what calls each
HookCalled byOverridden for
get_queryset()list(), retrieve(), update(), destroy()per-request row filtering
get_serializer_class()get_serializer()per-action/method serializer choice
get_serializer_context()get_serializer()extra context beyond request/view/format
perform_create/update/destroy()create(), update(), destroy()a side effect or extra field around the save/delete

Together

python
class ArticleViewSet(viewsets.ModelViewSet):
    serializer_class = ArticleSerializer

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

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

Remember: get_queryset()/get_serializer_class()/get_serializer_context() are the per-request override points generic views and ViewSets call instead of reading class attributes directly — always call self.get_serializer(), never a serializer class directly, to keep context/class-selection consistent. perform_create()/update()/destroy() are where a side effect or a server-controlled field (like owner=self.request.user) belongs — never accept that kind of field as writable serializer input.

See also: apiview generic views and mixins · viewsets and routers · serializer validation

Advertisement