Routers can feel almost suspicious the first time you use Django REST Framework: one short register() call appears to create list, detail, create, update, and delete URLs. There is no hidden request dispatcher, though. A router simply examines a ViewSet and builds ordinary Django URL patterns with consistent paths and names.

The answer in 30 seconds

Register a ViewSet with router.register(prefix, viewset, basename=...), then include router.urls in Django’s URL configuration. A conventional ViewSet can produce collection routes such as GET /users/ and POST /users/, plus detail routes such as GET, PUT, PATCH, and DELETE /users/{pk}/.

What a router does—and what it does not

  • It maps HTTP methods to ViewSet actions such as list, create, retrieve, update, partial_update, and destroy.

  • It creates Django URL patterns and stable route names such as user-list and user-detail.

  • It adds routes for methods decorated with @action.

  • It does not query the database, serialize objects, authenticate callers, authorize actions, or render HTML.

  • It does not automatically create nested resource semantics such as /authors/7/books/; model relationships and URL nesting are different concerns.

  • It is optional. Explicit path() entries remain a good fit for unusual endpoints or APIs where every route should be visible in one file.

A small API worth understanding

The example uses Django’s built-in User model so the routing mechanics stay visible. In a real service, expose only fields and operations your authorization policy permits; user administration is security-sensitive.

accounts/serializers.pypython
from django.contrib.auth import get_user_model
from rest_framework import serializers
 
User = get_user_model()
 
 
class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["id", "username", "email", "is_active"]
        read_only_fields = ["id"]

Why the serializer is deliberately boring

  • get_user_model() respects a project’s configured user model instead of importing Django’s default class directly.

  • ModelSerializer derives field behavior from the model, while fields explicitly limits the API representation.

  • The serializer validates and transforms data; it has no responsibility for URL generation.

  • Do not expose password hashes, permissions, staff flags, or personal data merely because the model contains them.

accounts/views.pypython
from django.contrib.auth import get_user_model
from rest_framework import permissions, viewsets
 
from .serializers import UserSerializer
 
User = get_user_model()
 
 
class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.order_by("id")
    serializer_class = UserSerializer
    permission_classes = [permissions.IsAdminUser]

The ViewSet owns request behavior

  • ModelViewSet combines the six standard CRUD actions used by router-generated routes.

  • The queryset gives the router enough model metadata to infer the default basename user.

  • serializer_class controls input validation and output representation.

  • IsAdminUser is an intentional security boundary; routing an endpoint does not make it safe.

  • For read-only endpoints, ReadOnlyModelViewSet limits generated method mappings to list and retrieve behavior.

config/urls.pypython
from django.contrib import admin
from django.urls import include, path
from rest_framework.routers import DefaultRouter
 
from accounts.views import UserViewSet
 
router = DefaultRouter()
router.register("users", UserViewSet, basename="user")
 
urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include(router.urls)),
]

Those three routing lines do distinct work

  • The prefix users becomes the collection path beneath /api/; do not include leading or trailing slashes in the prefix.

  • The ViewSet class supplies actions that the router can map to HTTP methods.

  • The explicit basename produces names beginning with user-; it is not part of the URL path.

  • include(router.urls) mounts the generated Django patterns below /api/.

  • Supplying basename explicitly keeps reverse names stable if the ViewSet later obtains its queryset dynamically.

Routes generated for ModelViewSet

  • GET /api/users/list() → route name user-list.

  • POST /api/users/create() → route name user-list.

  • GET /api/users/{pk}/retrieve() → route name user-detail.

  • PUT /api/users/{pk}/update() → route name user-detail.

  • PATCH /api/users/{pk}/partial_update() → route name user-detail.

  • DELETE /api/users/{pk}/destroy() → route name user-detail.

The same path name can accept multiple HTTP methods because Django resolves the path first and the ViewSet’s generated view then dispatches the method to an action. Unsupported methods return HTTP 405 rather than silently choosing another action.

SimpleRouter versus DefaultRouter

  • SimpleRouter creates standard collection, detail, and extra-action patterns. It does not add an API root view.

  • DefaultRouter includes those routes, adds a browsable API root that links registered list endpoints, and supports optional format suffix patterns.

  • Neither class turns JSON into the browsable interface. DRF content negotiation and renderers provide that representation.

  • Choose SimpleRouter for a minimal surface or when your project supplies its own API index. Choose DefaultRouter when its root view is useful and intentionally exposed.

  • Both use trailing slashes by default. A project can construct a router with trailing_slash=False, but changing this after clients ship is an API compatibility decision.

When basename cannot be inferred

accounts/views.py (dynamic queryset)python
class UserViewSet(viewsets.ModelViewSet):
    serializer_class = UserSerializer
    permission_classes = [permissions.IsAdminUser]
 
    def get_queryset(self):
        return get_user_model().objects.filter(is_active=True).order_by("id")

Why registration now needs a name

  • There is no class-level queryset whose model the router can inspect.

  • Register this ViewSet with router.register("users", UserViewSet, basename="user").

  • Without an inferable model and without basename, registration raises an assertion error instead of guessing.

  • Dynamic filtering belongs in get_queryset(); object permissions and tenant isolation still require explicit enforcement.

Add a custom route with @action

accounts/views.py (extra action)python
from rest_framework.decorators import action
from rest_framework.response import Response
 
 
class UserViewSet(viewsets.ModelViewSet):
    # queryset, serializer_class, and permissions omitted here for brevity
 
    @action(detail=True, methods=["post"], url_path="deactivate")
    def deactivate(self, request, pk=None):
        user = self.get_object()
        user.is_active = False
        user.save(update_fields=["is_active"])
        return Response({"status": "deactivated"})

How the extra route is derived

  • detail=True places the lookup value in the path: POST /api/users/{pk}/deactivate/.

  • The generated reverse name is user-deactivate, combining the basename and action URL name.

  • methods=["post"] rejects GET because the operation changes state.

  • self.get_object() applies queryset filtering and object-permission checks configured on the ViewSet.

  • The snippet demonstrates routing, not a complete account lifecycle; production deactivation may need transactions, audit events, session revocation, and domain rules.

Reverse routes instead of hard-coding URLs

accounts/tests/test_urls.pypython
from django.urls import reverse
from rest_framework.test import APITestCase
 
 
class UserRouteTests(APITestCase):
    def test_router_names(self):
        self.assertEqual(reverse("user-list"), "/api/users/")
        self.assertEqual(
            reverse("user-detail", kwargs={"pk": 42}),
            "/api/users/42/",
        )
        self.assertEqual(
            reverse("user-deactivate", kwargs={"pk": 42}),
            "/api/users/42/deactivate/",
        )

Route-name tests catch quiet breaking changes

  • reverse() uses names produced from the basename, not the Python ViewSet class name.

  • The default lookup keyword is pk; a ViewSet can deliberately use another lookup field.

  • Tests document the public path contract and detect accidental prefix, namespace, or trailing-slash changes.

  • These checks test URL generation only; add authenticated API tests for status codes, permissions, validation, and response bodies.

Inspect what Django actually registered

Project rootbash
python manage.py shell -c '
from config.urls import router
for pattern in router.urls:
    print(f"{pattern.name:24} {pattern.pattern}")'

The generated table is the best antidote to router confusion

  • Run the command with the same virtual environment and settings module as the application.

  • Each item is an ordinary Django URLPattern with a name and path pattern.

  • DefaultRouter output includes the API root and format-suffix variants in addition to ViewSet routes.

  • Inspecting patterns is safer than assuming a route from memory, especially after adding @action, namespaces, or custom lookup fields.

Exercise the API from the client side

Any terminalbash
curl -i -H "Accept: application/json" http://127.0.0.1:8000/api/users/
curl -i -X OPTIONS http://127.0.0.1:8000/api/users/

Read the response before changing the router

  • An unauthenticated or non-admin caller should receive the response dictated by authentication and permission settings, commonly 401 or 403.

  • HTTP 404 usually points to mounting, prefix, lookup, or trailing-slash mismatch.

  • HTTP 405 means the path resolved but that method is not mapped to an action.

  • The Accept header asks content negotiation for JSON; a browser may receive the browsable renderer when it is enabled.

  • OPTIONS can describe allowed methods and metadata, but it is not a replacement for API documentation or authorization tests.

Namespaces and hyperlinking need coordination

Mounting routes with an application namespace changes reverse names—for example, api:user-detail. Hyperlinked serializers must use a matching view_name, and clients or tests that reverse URLs must include the namespace. Namespace an API intentionally, then test every hyperlink rather than treating NoReverseMatch as a router defect.

Check the full reverse contract

  • Confirm the namespace assigned where the router URLs are included.

  • Combine namespace, basename, and action name exactly as Django registered them.

  • Match the serializer hyperlink view_name to that full route name.

  • Pass the configured lookup keyword and a value accepted by the ViewSet queryset.

Nested URLs are not built in

Core DRF routers do not automatically generate deeply nested resources. You can model filtering with a query parameter, write an explicit path, scope a ViewSet by a parent lookup, or adopt a maintained third-party nested-router package. Choose based on resource identity—not on a desire to make URLs look hierarchical—and verify parent/object authorization.

Common failures, translated

  • “basename argument not specified” — the ViewSet has no class-level queryset model to inspect; pass a stable basename.

  • 404 at `/api/`SimpleRouter has no root view, or the router is mounted elsewhere.

  • 404 without the final slash — the router uses trailing slashes and middleware redirect behavior may not apply safely to every HTTP method.

  • 405 Method Not Allowed — the route exists, but the ViewSet or @action does not map that method.

  • NoReverseMatch for a hyperlink — check basename, detail lookup, namespace, and serializer view_name.

  • An action is missing — ensure the method is inside the registered ViewSet and decorated with @action; then inspect router.urls.

  • The API root is visible to everyone — visibility is not authorization; configure authentication and permissions on every endpoint.

  • Two registrations collide — give distinct prefixes and basenames, especially when registering the same ViewSet more than once.

When explicit path() entries are clearer

  • A one-off webhook or health endpoint is not naturally a ViewSet resource.

  • The API intentionally uses non-CRUD paths and method semantics.

  • You need a tiny, auditable URL surface and router convention would obscure it.

  • Migration compatibility requires a precise legacy path or route name.

  • A mixed project can use routers for resource ViewSets and explicit paths for exceptional endpoints; this is normal Django composition, not a failure of architecture.

A practical review checklist

  • Choose SimpleRouter or DefaultRouter for a stated reason.

  • Treat prefix, basename, route names, lookup field, and slash policy as part of the public API contract.

  • Keep serializer fields narrow and permissions explicit.

  • Reverse route names in code instead of concatenating paths.

  • Inspect generated patterns after custom actions or namespace changes.

  • Test collection, detail, extra-action, authentication, permission, 404, and 405 behavior.

  • Document nested-resource decisions and prevent cross-parent object access.

  • Version breaking route changes rather than surprising existing clients.

Official references

  • DRF routers documents registration, generated routes, SimpleRouter, DefaultRouter, custom routers, and slash/path configuration.

  • DRF ViewSets explains actions, basename, reversing, and @action.

  • Django URL dispatcher explains the underlying path(), include(), namespaces, and reversing mechanisms.

  • DRF permissions covers view- and object-level authorization that routers do not provide.