Routers: automatic URL generation for ViewSets
Part 9 ended with URL patterns mapped by hand from TaskViewSet — exactly the kind of repetition a ViewSet exists to remove, still present one layer up in the URLconf. A Router generates that mapping automatically.
DefaultRouter
# tasks/urls.py
from rest_framework.routers import DefaultRouter
from .views import TaskViewSet
router = DefaultRouter()
router.register("tasks", TaskViewSet, basename="task")
urlpatterns = router.urlsrouter.register("tasks", TaskViewSet) replaces the entire manual .as_view({...}) block from part 9 with one line. basename="task" is what the generated URL names are built from (task-list, task-detail) — required whenever TaskViewSet.queryset doesn't make the model obvious enough for DRF to infer a name automatically, which is the common case in any real project.
What gets generated
GET/POST /tasks/ → task-list
GET/PUT/PATCH/DELETE /tasks/<pk>/ → task-detail
POST /tasks/<pk>/mark_complete/ → task-mark-completeEvery URL from part 9's hand-written version, plus the @action from part 9's mark_complete method — the router inspects TaskViewSet for both the six standard actions and any @action-decorated methods, and generates a matching URL pattern for each without any of them declared explicitly.
The browsable API's root view
# taskapi/urls.py
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("tasks.urls")),
]Visit http://127.0.0.1:8000/api/ and DefaultRouter (specifically — the plainer SimpleRouter doesn't do this) adds a root view listing every registered viewset as a clickable link. On a project with more than one viewset — tasks, and eventually users or categories — this becomes a genuinely useful index instead of something to remember URLs for by hand.
When to still write urls.py by hand
Not every endpoint is a resource a ViewSet fits naturally — an authentication endpoint (part 15) or a one-off aggregate view has no real "list/detail" shape. Those stay as plain path() entries alongside router.urls:
urlpatterns = router.urls + [
path("stats/", views.task_stats, name="task-stats"),
]Forgetting basename when a ViewSet overrides get_queryset() instead of declaring a plain queryset attribute (part 16 does exactly this for permissions). Without an inferable queryset, the router can't guess a name and raises an AssertionError at startup rather than silently misnaming routes — an explicit basename avoids it entirely.
The core CRUD API is done — list, create, retrieve, update, delete, all wired up with almost no boilerplate. Next: making sure the data going in is actually correct, beyond what field types alone can catch.