In a multi-tenant Django project that sets request.urlconf per tenant hostname, what changes for resolution, reverse() and the error handlers, and what breaks outside a request?
answer
- set before resolution, in middleware
- thread's active URLconf follows it
- handlers read from the active module
- background jobs see ROOT_URLCONF
basics
~10 sDjango resolves against request.urlconf instead of ROOT_URLCONF and from then on reverse(), {% url %} and the handler404/500 lookup follow it; code outside a request falls back to ROOT_URLCONF unless urlconf= is passed.
solid answer
~40 sA middleware must set `request.urlconf` before calling `get_response`, because Django resolves the URL right after the request phase of middleware; `process_view()` is too late. At resolution Django makes that module the thread's active URLconf, so from then on `reverse()`, `redirect()` by name and `{% url %}` use it; a middleware reversing before `get_response` still sees `ROOT_URLCONF`. The error handlers follow it too: `handler404` and `handler500` are looked up on the tenant module, and if it defines none you get Django's defaults, not the root module's branded pages. The system check only inspects `ROOT_URLCONF`. After the request the active URLconf resets, so a background job's `reverse()` uses `ROOT_URLCONF` and can raise `NoReverseMatch`; pass `urlconf=` explicitly.
code
python · 13 lines# config/urls_shop.py
from django.urls import path
from shop import views
urlpatterns = [
path("", views.storefront, name="storefront"),
path("invoices/<int:pk>/", views.invoice_detail, name="invoice-detail"),
]
# The ROOT_URLCONF module's handlers do not apply here.
handler404 = "pages.views.branded_not_found"
handler500 = "pages.views.branded_server_error"go deeper
Recall that request.urlconf, when set, replaces ROOT_URLCONF for that one request.
Explain when it must be set relative to URL resolution, and that reverse() and {% url %} follow it only once the URL has been resolved.
Demonstrate the traps: handlers read from the tenant module, checks that only see ROOT_URLCONF, and reverse() in background jobs needing urlconf= explicitly.
Judge whether tenants truly need different URL layouts or only different data, since per-request URLconfs add cache growth and out-of-request reversing costs.
## The mechanism Django chooses the URLconf per request in `BaseHandler`. At the start of every request it sets the thread's active URLconf to `ROOT_URLCONF`, then runs the middleware chain. When the chain reaches URL resolution, the handler checks the request: 1. If `request.urlconf` exists, Django makes it the active URLconf for the rest of the request (`set_urlconf()`) and resolves `request.path_info` against it. 2. Otherwise it resolves against `ROOT_URLCONF`. 3. The result is stored on `request.resolver_match`; only then do `process_view()` hooks and the view run. So the attribute must be set **before resolution**: in a middleware's request phase, before it calls `get_response`. Setting it in `process_view()` or in the view is too late. Setting `request.urlconf = None` in a later middleware reverts to `ROOT_URLCONF`. ```python # tenants/middleware.py TENANT_URLCONFS = { "shop.example.com": "config.urls_shop", "docs.example.com": "config.urls_docs", } class TenantURLConfMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): host = request.get_host().partition(":")[0] urlconf = TENANT_URLCONFS.get(host) if urlconf is not None: request.urlconf = urlconf return self.get_response(request) ``` ## What follows the active URLconf during the request Because Django records the choice as the thread's active URLconf rather than only on the request object, everything that asks for "the current URLconf" **from resolution onward** follows it: - **`reverse()`** with no `urlconf=` argument, `redirect()` given a view name, and the `{% url %}` tag all reverse against the tenant's module. - **The error handlers.** When a view raises `Http404` or crashes, Django looks up `handler404`, `handler500` and the others on the **active** URLconf module. If `config.urls_shop` defines none, the request gets Django's default views, **not** the branded handlers defined in the `ROOT_URLCONF` module. Each tenant URLconf needs its own handler assignments, typically imported from one shared module. - **The DEBUG 404 page** names the URLconf it used, which is the quickest way to confirm the middleware picked the right one. ## What breaks outside a request When the request finishes, Django resets the active URLconf. Code that runs with no request, such as a management command, a background job, a scheduled report or an email assembled later, sees only `ROOT_URLCONF`: - `reverse("invoice-detail", kwargs={"pk": 7})` raises `NoReverseMatch` if that name exists only in a tenant module, or silently builds a URL from the wrong layout if both define it. - `resolve()` likewise matches against the root module. The fix is to carry the tenant into the job and pass it explicitly: ```python from django.urls import reverse url = reverse("invoice-detail", kwargs={"pk": 7}, urlconf="config.urls_shop") ``` ## The window before resolution The switch happens at resolution, not when the attribute is assigned. During the request phase of every middleware, including the tenant middleware itself after it sets `request.urlconf`, the active URLconf is still `ROOT_URLCONF`: - a middleware that calls `reverse()` before `get_response`, for example to build a redirect to a tenant login page, reverses against the root module unless it passes `urlconf=request.urlconf`; - an exception raised in that phase, before any URL was resolved, is answered by the root module's error handlers, not the tenant's. ## Operational edges | Concern | What to know | |---|---| | System checks | `check_custom_error_handlers` inspects only the `ROOT_URLCONF` module, so a wrongly-shaped `handler404` in a tenant module is not reported as `urls.E007` | | Resolver cache | Django caches one resolver per URLconf value for the life of the process, without a size limit; map tenants onto a few layout modules rather than generating a module per tenant | | Host header | derive the tenant from `request.get_host()`, which validates against `ALLOWED_HOSTS`, not from the raw header | | Tests | request with the tenant's host through the test client, or call `resolve()`/`reverse()` with `urlconf=` | ## When this is the right tool A per-request URLconf suits a few distinct **URL layouts** behind one deployment: a storefront host and a documentation host, or a marketing site and an app host. When tenants share one layout and differ only in data, a single URLconf plus a tenant lookup in middleware or the views is simpler, and the reverse-outside-a-request problem never appears.
- Why does Django's system check miss a wrongly-shaped handler404 in a per-tenant URLconf?`check_custom_error_handlers` builds the resolver for `ROOT_URLCONF` only and checks the arity of that module's handlers. Tenant modules chosen at request time are never loaded by the check, so a handler with the wrong signature there fails only when a real 404 happens. A test that raises a 404 through each tenant's host closes the gap.
- What is the cost of generating a separate Django URLconf module per tenant?Django caches one URL resolver per URLconf value for the life of the process, with no size limit. A handful of layout modules is cheap; one generated module per tenant grows that cache with the tenant count in every worker. Map tenants onto a few layouts instead.
saying these in an interview costs you the question
- Setting request.urlconf in process_view() changes which view runs.
- A view's reverse() call ignores request.urlconf and uses ROOT_URLCONF.
- A tenant URLconf inherits the root module's handler404 automatically.
- The active per-request URLconf persists into later background jobs.
- manage.py check validates handlers in every URLconf a request may use.