In a Django REST Framework ModelViewSet, how do you vary permissions or serializers per action using self.action, and where does that break?
answer
- name of the running action
- set during request initialisation
- None and metadata exist
- allowlist the safe side
basics
~10 sBranch on self.action in get_permissions() or get_serializer_class(), or pass permission_classes to @action. It breaks when branches allowlist dangerous actions, forget partial_update, ignore None and metadata, or read self.action inside get_parsers() or get_authenticators().
solid answer
~40 sA ViewSet sets `self.action` from the router's mapping on each request: `list`, `create`, `retrieve`, `update`, `partial_update`, `destroy`, an extra action's method name, `metadata` for OPTIONS, or `None` when the route does not map that method. I override `get_permissions()` and `get_serializer_class()` to branch on it, or give an extra action its own `permission_classes` in `@action`. The traps: listing the dangerous actions and leaving the `else` open, so any new action or `None` gets the permissive branch; handling `update` but not `partial_update`; and reading `self.action` in `get_parsers()`, `get_authenticators()` or `get_content_negotiator()`, which run before it is set and raise `AttributeError`. I name the open actions and make the default strict.
code
python · 23 linesfrom rest_framework import permissions, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Project
from .serializers import ProjectSerializer
class ProjectViewSet(viewsets.ModelViewSet):
queryset = Project.objects.all()
serializer_class = ProjectSerializer
def get_permissions(self):
# Unsafe: 'archive', 'metadata' and None all fall through to AllowAny
if self.action in {"create", "update", "partial_update", "destroy"}:
return [permissions.IsAdminUser()]
return [permissions.AllowAny()]
@action(detail=True, methods=["post"])
def archive(self, request, pk=None):
project = self.get_object()
project.archive()
return Response(self.get_serializer(project).data)go deeper
Know that self.action names the running action and that get_permissions() and get_serializer_class() can branch on it.
List the values self.action can take, including metadata and None, and explain when it is set relative to parsers and authenticators.
Write deny-by-default branches so new actions start strict, and split a ViewSet whose policy branching has outgrown it.
Choose where per-action policy lives across the codebase, ViewSet hooks, decorator kwargs or permission classes, so reviews can audit it in one place.
## What self.action is Every DRF ViewSet request carries a **`self.action`** attribute naming the action being run. `ViewSetMixin.initialize_request()` sets it from the actions mapping that the router bound for that URL: | Request | `self.action` | |---|---| | `GET projects/` | `"list"` | | `POST projects/` | `"create"` | | `GET projects/<pk>/` | `"retrieve"` | | `PUT` / `PATCH projects/<pk>/` | `"update"` / `"partial_update"` | | `DELETE projects/<pk>/` | `"destroy"` | | `POST projects/<pk>/archive/` | `"archive"` — the extra action's method name | | `OPTIONS` on any route | `"metadata"` | | a method with no mapped action on that URL | `None` | Alongside it, `self.detail` tells you whether the route is a detail or collection route, and `self.basename` holds the router's basename. ## Varying policy per action The standard pattern overrides the generic hooks and branches on `self.action`: - **`get_permissions()`** — return a different list of permission instances per action; - **`get_serializer_class()`** — a slim serializer for `list`, a full one for `retrieve`, a write serializer for `create`; - **`get_queryset()`** — for example, add prefetching only for `retrieve`. For extra actions there is a shorter route: pass the attributes to the decorator, as in `@action(detail=True, methods=["post"], permission_classes=[IsAdminUser], serializer_class=ArchiveSerializer)`. These override the ViewSet's class attributes for that action only. ## Where it breaks 1. **Allowlisting the dangerous actions instead of the safe ones.** A `get_permissions()` written as "admin for `create`, `update`, `partial_update`, `destroy`, otherwise `AllowAny`" leaves every *other* value open: `None`, `"metadata"`, and every extra action added later. The day someone adds `archive`, it is public. Write the branch the other way round: name the actions that may be open, and make the `else` branch the strict one. 2. **Forgetting the twin action.** Branching on `"update"` alone misses `"partial_update"`, so PATCH gets the other branch's policy. 3. **Reading it too early.** `self.action` is assigned *after* `initialize_request()` has built the request's parsers, authenticators and content negotiator. Overriding `get_parsers()`, `get_authenticators()` or `get_content_negotiator()` and reading `self.action` there raises `AttributeError`. Permissions, throttles and serializers are fine: they run later, in `initial()` and in the action. 4. **Assuming `None` never happens.** A request for a method the route does not map still runs `initial()` — authentication, permissions, throttles — before dispatch looks for a handler; only if those checks pass does the client see 405. Your `get_permissions()` must cope with `self.action is None`. 5. **Serializer branching and the browsable API.** The browsable API renders forms by calling `get_serializer_class()`; a branch that assumes one action only can render the wrong form. ## A safer shape ```python from rest_framework import permissions, viewsets from .models import Project from .serializers import ProjectDetailSerializer, ProjectListSerializer OPEN_ACTIONS = {"list", "retrieve", "metadata"} class ProjectViewSet(viewsets.ModelViewSet): queryset = Project.objects.all() def get_permissions(self): if self.action in OPEN_ACTIONS: classes = [permissions.IsAuthenticated] else: classes = [permissions.IsAdminUser] return [cls() for cls in classes] def get_serializer_class(self): if self.action == "list": return ProjectListSerializer return ProjectDetailSerializer ``` With this shape, a new extra action is admin-only until someone deliberately adds it to `OPEN_ACTIONS` or passes `permission_classes` to its decorator. ## Testing per-action policy Per-action rules are easy to break silently, so test them as a matrix: for each route and method, and for each kind of caller (anonymous, ordinary user, admin), assert the expected status. Include: - every extra action, so a new action cannot ship with the permissive default; - PATCH as well as PUT, to catch the forgotten `partial_update`; - OPTIONS, which runs with `self.action` set to `"metadata"`; - a method the route does not map, to confirm the response is 405 or a denial, never a success. ## When branching gets heavy If `get_permissions()` and `get_serializer_class()` grow long `if` chains, the ViewSet is doing too much. Options include splitting it into two ViewSets (public and admin), moving action-specific attributes into `@action` keyword arguments, or putting per-action rules into a permission class that reads `view.action` — writing such classes is its own topic. The ViewSet should read as a short list of actions, not a policy engine.
- In DRF, is @action(permission_classes=[...]) enough to protect an extra action from a permissive get_permissions() override?No. The keyword sets the view's `permission_classes` attribute for that action, but a `get_permissions()` override that returns its own list without reading `self.permission_classes` ignores it. Either let the override fall back to `super().get_permissions()` for actions it does not name, or make its default branch strict.
- What value does self.action have on a DRF ViewSet for a DELETE sent to the collection URL?`None`, because the collection route maps only GET and POST (plus HEAD from GET). `initial()` still runs authentication, permissions and throttles with `self.action` as `None` before dispatch can return 405, so a permission branch must handle it — ideally by falling into the strict default.
saying these in an interview costs you the question
- self.action is always one of the six standard CRUD action names.
- Branching on update also covers PATCH requests.
- self.action can be read safely inside get_authenticators().
- New @action methods are admin-only by default in DRF.
- An OPTIONS request reports self.action as options.