Adding Custom Endpoints to a DRF ModelViewSet with @action
Learn how to extend a DRF ModelViewSet with custom endpoints using the @action decorator, including routing, HTTP methods, permission overrides, and a concrete worked example with verification steps.
17 Jul 2025, 00:50 UTC

The problem: you need a non‑CRUD endpoint on an existing ViewSet
When a ModelViewSet already handles list, create, retrieve, update, and destroy, adding a one‑off operation — like "publish" a draft article or "recalculate" a price — often leads developers to create a separate APIView or a function‑based view. That duplicates permission classes, authentication, throttling, and serialization logic. DRF’s @action decorator lets you attach the extra behavior directly to the ViewSet so the new URL inherits all the existing configuration.
How @action works
The decorator registers a method on the ViewSet as a routable endpoint. Two arguments control the scope:
- detail=True (default) – the URL includes the instance lookup (e.g.,
/api/articles/42/publish/). - detail=False – the URL sits at the collection level (e.g.,
/api/articles/bulk-publish/).
You also declare the allowed HTTP methods, a custom URL path, and an optional URL name. DRF’s DefaultRouter (or SimpleRouter) automatically builds the route using the ViewSet’s basename plus the supplied path.
Worked example: a “summary” endpoint for a Book model
Assume a minimal Django 4.2 project with djangorestframework==3.15 installed.
1. Model and serializer
# models.py
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.CharField(max_length=100)
published = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
# serializers.py
from rest_framework import serializers
from .models import Book
class BookSerializer(serializers.ModelSerializer):
class Meta:
model = Book
fields = '__all__'
2. ViewSet with a custom list‑level action
# views.py
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Book
from .serializers import BookSerializer
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookSerializer
@action(detail=False, methods=['get'], url_path='summary', url_name='summary')
def summary(self, request):
"""Return a lightweight aggregation for the whole collection."""
total = self.get_queryset().count()
published = self.get_queryset().filter(published=True).count()
data = {
'total_books': total,
'published_books': published,
'unpublished_books': total - published,
}
return Response(data, status=status.HTTP_200_OK)
3. Router registration
# urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import BookViewSet
router = DefaultRouter()
router.register(r'books', BookViewSet, basename='book')
urlpatterns = [
path('api/', include(router.urls)),
]
4. Verify the generated route
Run the development server and inspect the router’s URL patterns:
$ python manage.py shell -c "
from myapp.urls import router
for u in router.urls:
print(u.pattern, u.callback.actions)
"
You should see a line similar to:
^api/books/summary/$ {'get': 'summary'}
5. Call the endpoint
$ curl -i http://127.0.0.1:8000/api/books/summary/
HTTP/1.1 200 OK
Content-Type: application/json
{"total_books": 12, "published_books": 7, "unpublished_books": 5}
Trade‑offs and limitations
- Route collisions – If you choose a
url_paththat matches an existing router pattern (e.g.,listordetail), DRF raises aRuntimeErrorat startup. Keep custom paths unique. - Version compatibility – Prior to DRF 3.9 the decorators were named
@detail_routeand@list_route. Using@actionon an older version triggers anImportError. Pindjangorestframework>=3.9in your requirements. - Permission granularity – The action inherits the ViewSet’s permission classes. If you need a different policy, override it in the decorator:
@action(..., permission_classes=[IsAdminUser]). - Testing – Because the endpoint lives on the ViewSet, you can test it with the same
APIClienthelpers you use for CRUD actions, but remember to include the custom URL name when reversing:reverse('book-summary').
Actionable closing
Next time you need a bespoke operation on a resource that already has a ModelViewSet, add a method decorated with @action instead of spinning up a separate view. It keeps routing, authentication, throttling, and serialization in one place, reduces boilerplate, and makes the API surface easier to discover via the router’s generated URL list. Verify the new route at startup (print router.urls) and run a quick curl or automated test to confirm the response shape. If you hit a RuntimeError on import, rename the url_path to something unique.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.