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, anddestroy.It creates Django URL patterns and stable route names such as
user-listanduser-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.
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.ModelSerializerderives field behavior from the model, whilefieldsexplicitly 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.
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
ModelViewSetcombines the six standard CRUD actions used by router-generated routes.The
querysetgives the router enough model metadata to infer the default basenameuser.serializer_classcontrols input validation and output representation.IsAdminUseris an intentional security boundary; routing an endpoint does not make it safe.For read-only endpoints,
ReadOnlyModelViewSetlimits generated method mappings to list and retrieve behavior.
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
usersbecomes 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
basenameexplicitly keeps reverse names stable if the ViewSet later obtains its queryset dynamically.
Routes generated for ModelViewSet
GET /api/users/→list()→ route nameuser-list.POST /api/users/→create()→ route nameuser-list.GET /api/users/{pk}/→retrieve()→ route nameuser-detail.PUT /api/users/{pk}/→update()→ route nameuser-detail.PATCH /api/users/{pk}/→partial_update()→ route nameuser-detail.DELETE /api/users/{pk}/→destroy()→ route nameuser-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
SimpleRoutercreates standard collection, detail, and extra-action patterns. It does not add an API root view.DefaultRouterincludes 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
SimpleRouterfor a minimal surface or when your project supplies its own API index. ChooseDefaultRouterwhen 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
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
querysetwhose 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
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=Trueplaces 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
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
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
URLPatternwith a name and path pattern.DefaultRouteroutput 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
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
Acceptheader asks content negotiation for JSON; a browser may receive the browsable renderer when it is enabled.OPTIONScan 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_nameto 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/` —
SimpleRouterhas 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
@actiondoes 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 inspectrouter.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
SimpleRouterorDefaultRouterfor 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.
Comments and corrections