backend / web frameworks / django / drf / 05_routers_actions.md

DRF Routers and Custom Actions

5 interview angles 3 min read source

DRF Routers and Custom Actions

Routers turn a ViewSet class into a set of URLs by inspecting the action methods on the class. They only work with ViewSet subclasses, not plain APIView/GenericAPIView.

DefaultRouter vs SimpleRouter

from rest_framework.routers import DefaultRouter, SimpleRouter

router = DefaultRouter()  # adds an API root view at /, plus .json/.api format suffixes
router.register("books", BookViewSet, basename="book")
Feature SimpleRouter DefaultRouter
CRUD URLs from viewset yes yes
API root view (/) listing all registered routes no yes
Format suffix patterns (.json, .api) no yes

Use SimpleRouter in production for cleaner URL space; DefaultRouter is convenient in dev for the browsable API root.

What register() produces

For router.register("books", BookViewSet, basename="book"):

URL Method Action URL name
/books/ GET list book-list
/books/ POST create book-list
/books/{pk}/ GET retrieve book-detail
/books/{pk}/ PUT update book-detail
/books/{pk}/ PATCH partial_update book-detail
/books/{pk}/ DELETE destroy book-detail

basename is used to build URL names (reverse("book-detail", args=[1])). It’s required when the router can’t infer it from queryset (e.g. when you override get_queryset() and remove the class attribute).

Custom actions with @action

For endpoints that don’t fit CRUD: POST /books/{pk}/publish/, GET /books/recent/.

from rest_framework.decorators import action
from rest_framework.response import Response

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer

    @action(detail=True, methods=["post"])
    def publish(self, request, pk=None):
        book = self.get_object()
        book.published_at = timezone.now()
        book.save()
        return Response({"status": "published"})

    @action(detail=False)              # default method is GET
    def recent(self, request):
        recent = self.get_queryset().order_by("-created_at")[:10]
        return Response(self.get_serializer(recent, many=True).data)
Param Effect
detail=True URL is /books/{pk}/<action>/; calls get_object()
detail=False URL is /books/<action>/; collection-level
methods=["post", "patch"] HTTP methods accepted (default ["get"])
url_path="mark-as-read" Override URL segment (default = method name)
url_name="mark_read" Override URL name (default = method name with _)
permission_classes=[...] Override viewset permissions for this action only
serializer_class=... Override serializer for this action only

Adding non-router URLs alongside a viewset

from rest_framework.routers import DefaultRouter
from django.urls import path, include

router = DefaultRouter()
router.register("books", BookViewSet)

urlpatterns = [
    path("api/", include(router.urls)),
    path("api/health/", HealthView.as_view()),  # non-router
]

Manually wiring a ViewSet without a router

You don’t have to use a router. Useful when you need exotic URL shapes:

book_list = BookViewSet.as_view({"get": "list", "post": "create"})
book_detail = BookViewSet.as_view({"get": "retrieve", "put": "update", "delete": "destroy"})

urlpatterns = [
    path("books/", book_list),
    path("books/<int:pk>/", book_detail),
]

as_view({"http_method": "action_name"}) is what the router does internally.

Nested resources

DRF’s built-in routers don’t do nested URLs. Two options:

  1. drf-nested-routers package — handles /authors/{author_pk}/books/{pk}/ routing and exposes the parent pk in self.kwargs["author_pk"].
  2. Manual — define a BookByAuthorViewSet with get_queryset(self) reading self.kwargs["author_pk"], register it on a separate router prefix.
# With drf-nested-routers
authors_router = DefaultRouter()
authors_router.register("authors", AuthorViewSet)

books_router = NestedDefaultRouter(authors_router, "authors", lookup="author")
books_router.register("books", BookViewSet, basename="author-books")

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

class BookViewSet(viewsets.ModelViewSet):
    def get_queryset(self):
        return Book.objects.filter(author_id=self.kwargs["author_pk"])

Common router gotchas

  • basename required when queryset is missing. Routers infer the basename from viewset.queryset.model._meta.object_name. If you override get_queryset() and remove the class attribute, you must pass basename=.
  • Order matters. Custom action URLs are registered in declaration order; if two actions resolve to the same path, the later one wins.
  • @action(detail=False) without a custom url_path collides with list. /books/ and /books/recent/ are fine, but /books/list/ (action named list) shadows the built-in.
  • as_view() on a viewset takes a method-to-action dict. BookViewSet.as_view() without args raises TypeError.

Interview angle

  • “What does a router give you that manual path() calls don’t?” — auto-generates URL patterns and names for all six CRUD actions plus @actions; centralizes API surface area.
  • “How does a @action(detail=True) URL look vs detail=False?”/resource/{pk}/<action>/ vs /resource/<action>/. detail=True calls get_object() automatically.
  • “How do you give a custom action a different permission class?”permission_classes=[...] argument on @action, or override get_permissions() and check self.action.
  • “Why does the router complain basename argument not specified?” — your viewset has no .queryset class attribute (only get_queryset()), so it can’t infer the model name.
  • “Nested resources — does DRF support them out of the box?” — no, use drf-nested-routers or wire manually with self.kwargs["parent_pk"].