When a ViewSet already covers CRUD but you need one extra operation — publishing a post, or listing featured posts — do not create another ViewSet or a standalone APIView. Just attach the @action decorator to a method inside the ViewSet: DRF generates the route itself and keeps the ViewSet’s permission, serializer, and queryset. This works for any operation standard CRUD does not offer, on a single object as well as on a list.
The problem
A ModelViewSet only ships five operations: list, retrieve, create, update, and destroy. To add a “publish” button, many people write a separate APIView and register its route by hand:
class PublishPostView(APIView): def post(self, request, pk): post = Post.objects.get(pk=pk) post.is_published = True post.save() return Response({"is_published": True})
This runs, but it quickly gets messy: the queryset, permission, and serializer have to be written again, the hand-registered route can drift from convention, and the logic for one resource ends up scattered in two places. Every new operation adds another class, another route, and another place to remember when permissions change.
The solution
Put the operation right inside the ViewSet and mark it with @action. detail=True means an operation on one object, detail=False an operation on the list:
from rest_framework.decorators import actionfrom rest_framework.response import Responsefrom rest_framework import viewsetsclass PostViewSet(viewsets.ModelViewSet): queryset = Post.objects.all() serializer_class = PostSerializer @action(detail=True, methods=["post"]) def publish(self, request, pk=None): post = self.get_object() post.is_published = True post.save(update_fields=["is_published"]) return Response(self.get_serializer(post).data) @action(detail=False, methods=["get"]) def featured(self, request): posts = self.get_queryset().filter(is_featured=True) return Response(self.get_serializer(posts, many=True).data)
Just register the ViewSet with the router as usual and DRF generates two routes: POST /posts/{pk}/publish/ and GET /posts/featured/. Three benefits come with it: self.get_object() and self.get_queryset() reuse the ViewSet’s queryset, even when you overrode get_queryset() to filter by user; the ViewSet’s permission and authentication apply to the action as well, no need to declare them again; and the method name is the route name, so reading the ViewSet shows every endpoint of that resource.
Two details people often forget. For read-only actions, set methods=["get"]; for actions that change data, use post, patch, or delete with the right semantics — never use get to modify data. If the method name does not make a good URL, pass url_path="another-name" to the decorator to rename the path without renaming the method.
Apply it to your project
Open the ViewSet with the most helper APIViews around it and review: are endpoints like approve, archive, duplicate, or export sitting outside the ViewSet? Pull them back in as @actions, one job per action, returning the resource’s own serializer. Push the business logic into a model method, for example post.publish(), so the action only fetches the object, calls the method, and returns the response.
Related posts
- Django REST Framework: Pick the Right Serializer per Action with get_serializer_class() in 5 Minutes
- Django REST Framework: Filter, Search, and Sort Your API with django-filter in 5 Minutes
- Django: Fetch Only the Records You Need with Conditional Prefetch() in 5 Minutes
Frequently asked questions
How is @action different from overriding create or update?
Overriding create/update only changes the behavior of an existing CRUD operation, while @action creates a brand-new endpoint. When you need a separate verb like publish or archive, @action is the right tool — do not squeeze that logic into update and distinguish it with a flag in the body.
What is the difference between detail=True and detail=False?
detail=True creates a route with {pk} and fetches the object with self.get_object(); detail=False creates a route on the collection, with no {pk}, working with self.get_queryset(). Mixing up the two is the most common mistake.
Can you rename an action’s URL?
Yes. Pass url_path to the decorator — for example @action(detail=True, methods=["post"], url_path="xuat-ban") creates the route /posts/{pk}/xuat-ban/ while the method stays named publish.
Does an action inherit the ViewSet’s permission?
Yes, by default an action shares the ViewSet’s permission_classes. If one action needs looser or stricter rules, pass permission_classes=[...] right in the @action decorator — it applies to that action only, not to the rest of the endpoints.
💬 Have you tried this in your own project? If you hit an error or know a cleaner way, leave a comment below — I read them all and will fold the best ideas into the post.
Sources
- Django REST Framework — ViewSets, Marking extra actions for routing: https://www.django-rest-framework.org/api-guide/viewsets/#marking-extra-actions-for-routing
- Django REST Framework — Routers: https://www.django-rest-framework.org/api-guide/routers/
