skip to content

Two Django REST Framework ModelViewSets over the same Project model are registered on one router without basename; what happens in DRF 3.18, and what other route collisions should you check?

level: seniorimportance: should knowfreq 30%

answer

  1. both infer the model name
  2. startup error since 3.15
  3. check is per router only
  4. collection actions match first

basics

~20 s

Both ViewSets infer the basename project, and since DRF 3.15 register() raises ImproperlyConfigured for the duplicate. The check is per router and basename only, so cross-router names, url_name clashes and collection actions shadowing lookup values still need review.

solid answer

~40 s

Without `basename`, the router derives it from each ViewSet's `queryset` model, so both get `project`. In DRF 3.18 the second `register()` raises `ImproperlyConfigured` saying the basename is already registered; the fix is an explicit, unique basename such as `admin-project`. Before 3.15 this was silent: duplicate `project-list`/`project-detail` names meant hyperlinks and `reverse_action()` resolved to only one ViewSet. The check does not cover everything: two routers in one URLconf are not compared, an `@action` with `url_name="detail"` duplicates the detail name, and a `detail=False` action at `archived/` shadows a project whose slug is `archived`, because collection actions are matched before the detail route. I print `router.urls` and add a reverse-and-resolve test for every route name.

code

python · 24 lines
python
from django.test import SimpleTestCase
from django.urls import resolve, reverse

from projects.views import AdminProjectViewSet, ProjectViewSet

EXPECTED = {
    ("project-list", ()): ProjectViewSet,
    ("project-detail", ("alpha",)): ProjectViewSet,
    ("project-archived", ()): ProjectViewSet,
    ("admin-project-list", ()): AdminProjectViewSet,
    ("admin-project-archive", ("alpha",)): AdminProjectViewSet,
}


class RouteNameTests(SimpleTestCase):
    def test_every_route_name_resolves_to_its_viewset(self):
        for (name, args), viewset in EXPECTED.items():
            with self.subTest(name=name):
                match = resolve(reverse(name, args=args))
                self.assertIs(match.func.cls, viewset)

    def test_archived_slug_reaches_the_detail_route(self):
        match = resolve(reverse("project-detail", args=["archived"]))
        self.assertEqual(match.func.actions.get("get"), "retrieve")

go deeper

for a junior

Know that route names come from the basename and that a model exposed twice needs an explicit basename.

for a middle

Explain basename inference, the 3.15 duplicate check, and the order in which the router lists collection actions and detail routes.

for a senior

Diagnose silent collisions by printing router.urls and resolving names, restrict lookups, and add a route-name test to the suite.

for a principal

Own the naming convention and router layout across API areas so collisions are prevented by design rather than found in production.

## The scenario A team exposes projects twice: a public `ProjectViewSet` and an `AdminProjectViewSet` with extra fields and actions. Both are `ModelViewSet`s with `queryset = Project.objects.all()`, registered on one DRF router without a `basename`: ```python router = routers.DefaultRouter() router.register("projects", ProjectViewSet) router.register("admin/projects", AdminProjectViewSet) ``` ## What DRF 3.18 does When `register()` gets no basename, the router infers one from the ViewSet's `queryset` attribute: the lower-cased model name, `project`, for **both** ViewSets. Since DRF 3.15, `register()` checks the router's registry and raises **`ImproperlyConfigured`**: "Router with basename "project" is already registered. Please provide a unique basename for viewset …". The error surfaces the first time the URLconf module is imported, so the collision fails loudly instead of misbehaving silently. The fix is an explicit, distinct basename for at least one registration — `basename="admin-project"` — which yields `admin-project-list`, `admin-project-detail` and `admin-project-<url_name>`. ## What older releases did Before 3.15 the second registration was accepted. Both ViewSets produced patterns with identical names (`project-list`, `project-detail`), so reversing those names — in hyperlinked serializer fields, in `reverse_action()`, in tests — could resolve to only one of the two ViewSets. The symptom was hyperlinks in one API pointing at the other API's URLs, with no error anywhere. Projects upgrading across 3.15 sometimes meet the new exception for exactly this reason: it exposes a collision that had been silently wrong. ## Collisions the basename check does not catch The uniqueness check is **per router instance** and only compares basenames. Several other collisions pass it: | Collision | How it happens | Symptom | |---|---|---| | Two routers, same basename | a v1 router and an admin router each register `project`, both included in one URLconf | duplicate route names; reversing picks one | | Extra action vs lookup value | `@action(detail=False, url_path="archived")` on a ViewSet whose `lookup_field` is a slug | a project with slug `archived` can never reach its detail URL, because collection actions are matched before the detail route | | `url_name` vs standard name | `@action(detail=False, url_name="detail")` | two patterns named `project-detail`; reversing the name resolves to only one of them | | Standard action name | decorating a method called `list` or `destroy` with `@action` | caught: URL generation raises `ImproperlyConfigured` | ## Diagnosing a naming problem When hyperlinks point at the wrong place or a detail URL returns an action's response: 1. **Print the generated table.** In `manage.py shell`, iterate over each router's `urls` and print every pattern with its name; duplicates and ordering problems are visible immediately. 2. **Check which ViewSet a name resolves to.** Reverse the suspicious name and resolve the resulting path; compare the resolved view class with the one you expected. 3. **Check lookup patterns.** A lookup that accepts any string — the default, which excludes only slashes and periods — lets collection actions shadow real objects. Restrict it with `lookup_value_regex`, or with `lookup_value_converter` when the router uses path converters. 4. **Separate multiple routers** by URL namespace so their names cannot collide, and make basenames unique anyway. ## Why hyperlinks are the first thing to break Hyperlinked serializers build each object's `url` field by reversing `<basename>-detail`. When two registrations share a basename, or two routers share one, every serializer that reverses that name produces the same URL shape — so the admin API's responses can contain links into the public API, or the reverse. Nothing raises; clients simply follow links to the wrong endpoint, often one with different permissions. That is why a route-name collision is a correctness and access-control problem, not a cosmetic one, and why it deserves a test rather than a code-review reminder. ## Preventing it - Always pass `basename` explicitly when a model is exposed more than once, or when the ViewSet defines only `get_queryset()`. - Adopt a naming convention that includes the API area: `admin-project`, `public-project`. - Avoid collection-action paths that could be valid lookup values, or restrict the lookup so they cannot be. - Add a test that reverses every expected route name and resolves the resulting path back to the intended ViewSet class and action. It is cheap and catches both the silent pre-3.15 behaviour and the ordering trap. A router saves a lot of URLconf code, but the URLs and names it produces are still part of the API contract, and a senior engineer treats the generated table as something to review, not something to trust.

  • Why does a DRF collection action at projects/archived/ win over the detail route for a project with slug archived?
    `SimpleRouter`'s route table lists the collection-level dynamic route before the detail route, so the generated patterns appear in that order and Django resolves the first match. With a slug lookup that accepts any string, `archived` matches both; the action wins. Restricting the lookup pattern or choosing a path that cannot be a valid slug removes the ambiguity.
  • Does DRF's duplicate-basename check protect two routers included in the same URLconf?
    No. The check compares a new registration against that router instance's own registry. Two routers each registering `project` pass, and the URLconf ends up with duplicate route names. Namespacing each router's include and choosing distinct basenames are the defences; a reverse-and-resolve test catches what slips through.

saying these in an interview costs you the question

  • The router appends a number to make duplicate basenames unique.
  • The basename check covers every router included in the project.
  • Detail routes are always matched before collection-level extra actions.
  • Duplicate route names are harmless because each URL is still different.
  • An @action's url_name can never clash with a standard route name.