Our post on DRF views ended on an unresolved thread: every view we built - CountryListView, PurchaseViewSet, PurchasedItemDetailView - still needed something pointing an actual URL at it. We never built that part. This is where it gets resolved.

Writing the URLs by hand, for real this time

Let’s start with our full CRUD table. Purchase needs six operations wired up - list, create, retrieve, update, delete, and the custom refund action from last time. Written the way Django’s always done it - one path() per operation - that’s:

#purchases/urls.py
from django.urls import path
from .views import PurchaseListView, PurchaseDetailView, PurchaseRefundView

urlpatterns = [
    path('purchases/', PurchaseListView.as_view()),
    path('purchases/<int:pk>/', PurchaseDetailView.as_view()),
    path('purchases/<int:pk>/refunds/', PurchaseRefundView.as_view()),
]

Three path() calls, but notice what’s hiding behind them: PurchaseListView needs get() and post() written out, PurchaseDetailView needs get(), put(), and delete(), and PurchaseRefundView needs its own post() - the exact eight-method breakdown from the DRF views article, still here, just organized under three URLs instead of laid bare. The URLs don’t remove the repetition, they just group it.

There’s a second problem worth naming, though - one that only shows up once you try to wire up the real PurchaseViewSet instead of these hand-written stand-ins. ViewSets don’t work like a plain APIView. You can’t just write PurchaseViewSet.as_view() - without a router, .as_view() needs an explicit dictionary mapping HTTP methods to the action they should call:

#purchases/urls.py, PurchaseViewSet wired up by hand

urlpatterns = [
    path('purchases/', PurchaseViewSet.as_view({
        'get': 'list',
        'post': 'create',
    })),
    path('purchases/<int:pk>/', PurchaseViewSet.as_view({
        'get': 'retrieve',
        'put': 'update',
        'patch': 'partial_update',
        'delete': 'destroy',
    })),
    path('purchases/<int:pk>/refunds/', PurchaseViewSet.as_view({
        'post': 'refunds',
    })),
]

This is the actual cost of a ViewSet, wired up without a router - not just repetition, but a second kind of bookkeeping on top: for every URL, you’re the one deciding which HTTP method calls which method on the class. Get one of those mappings wrong - say 'put': 'destroy' - and nothing warns you; it just quietly deletes on what looks like an update.

What router.register() replaces

Here’s the same thing, using what’s actually in purchases/urls.py:

#purchases/urls.py
from rest_framework.routers import DefaultRouter
from django.urls import path, include
from .views import PurchaseViewSet, PurchasedItemDetailView

router = DefaultRouter()
router.register(r'purchases', PurchaseViewSet, basename='purchase')

urlpatterns = [
    path('', include(router.urls)),
    path('purchased-items/<int:pk>/', PurchasedItemDetailView.as_view(), name='purchased-item-detail'),
]

Two lines - router = DefaultRouter() and router.register(...) - replace both the three hand-written path() calls and the three method-mapping dictionaries above. The router already knows that a ModelViewSet has list/create/retrieve/update/partial_update/destroy, and it already knows how to map each one to the right HTTP method on the right URL. It also finds refunds automatically, because it’s decorated with @action - nothing about that URL was written by hand anywhere.

Notice what’s conspicuously absent from this file: PurchasedItemDetailView is still there, still wired with a plain path(), no router involved. That’s not an oversight - it’s the actual point.

The router only helps where there’s a ViewSet

Across our four tables, only one of them uses a router at all:

  • Purchase - full CRUD plus refunds, a ModelViewSet, registered with DefaultRouter
  • Country - list only, a plain generics.ListAPIView, wired with path()
  • PurchasedItem - retrieve and update only, a plain generics.RetrieveUpdateAPIView, wired with path()
  • ShippingStatus - one lookup by tracking number, a bare APIView, wired with path()

The router isn’t a general-purpose replacement for path() - it’s specifically for ViewSets, because a ViewSet is the one kind of view that bundles multiple actions together in a way a router can inspect and auto-wire. Country, PurchasedItem, and ShippingStatus were never candidates for a router in the first place; they’re single-purpose views, and path() was already the right, simplest tool for them - same as it’s always been.

Proof: the browsable API

DefaultRouter does one more thing worth mentioning before the screenshots - it builds a root view at /api/, listing every endpoint it registered. Visit it and all you’ll see is {"purchases": "http://127.0.0.1:8000/api/purchases/"} - one entry. Not countries, not purchased-items, not shipping-status. None of them are missing by accident; none of them were ever registered with the router, so the router has no way to know they exist. That single-entry list is built entirely from router.register() calls, nothing else.

/api/purchases/ - the list and create actions, both reachable from one URL, distinguished only by HTTP method:

Browsable API view of /api/purchases/ showing the list and create actions on one URL

/api/purchases/45/ - retrieve, update, and delete, same story:

Browsable API view of /api/purchases/45/ showing the retrieve, update, and delete actions

And /api/purchases/45/refunds/ - the custom action, reachable at a URL nobody wrote by hand:

Browsable API view of the custom /api/purchases/45/refunds/ action, a URL generated by the router

Worth being honest about what this actually proves: the refunds action itself is still a stub - return Response({"status": "refunded", "purchase": purchase.id}), nothing persisted, no real refund logic. What this screenshot proves is narrower and still real: the mechanism works. A method decorated with @action on a router-registered ViewSet gets a working URL, with the right HTTP method enforced, without a single line of URL configuration written by hand.

For contrast - /api/purchased-items/45/, never touched by a router:

Browsable API view of /api/purchased-items/45/ showing Allow: GET, PUT, PATCH but no POST or DELETE

Allow: GET, PUT, PATCH, HEAD, OPTIONS - no POST, no DELETE. That’s not the router being selective; there’s no router here at all, just RetrieveUpdateAPIView doing exactly what it was built to do, same as it would with or without a router existing anywhere in the project.

/api/countries/ makes the same point from a different angle - a table that never had a ViewSet in the first place, so a router was never even a candidate:

Browsable API view of /api/countries/, a table wired with plain path() and no router

One last one, for the record rather than for the router argument specifically - /api/shipping-status/<tracking_number>/, the one table with no model, no queryset, and (like Country and PurchasedItem) no router:

Browsable API view of /api/shipping-status/<tracking_number>/, a view with no model, queryset, or router

Where this leaves us

Four tables, one router. Not because the other three were forgotten, but because a router only pays for itself when there’s a ViewSet bundling multiple actions together - and even then, what it’s actually buying you isn’t magic, it’s exactly the same path() calls and method-mapping dictionaries we wrote by hand earlier in this article, generated instead of typed.