Filter concepts by levelShowing all levels.

Django · Section 5

URL Routing

Level
intermediate
Read
26 min
Concepts
4

Regex-based routing with re_path() for patterns path() can't express, application and instance namespaces for disambiguating an app included more than once, reverse()/reverse_lazy() for turning a URL name back into a path, and conventions for versioning an API and naming URLs consistently.

What is true here

  1. re_path() matches with a regular expression — every captured named group arrives as a plain string, never auto-converted like path()'s converters.
  2. app_name (set once, in the app's own urls.py) and include()'s namespace= (set per-include) together disambiguate an app mounted more than once.
  3. reverse() resolves a URL name immediately; reverse_lazy() defers resolution — required for anything evaluated at import/class-definition time.
  4. Version an API by URL path prefix (/api/v1/, /api/v2/), backed by a fully separate urls.py per version.
  5. Name a URL after its resource and action, not its HTTP method — a single URL can serve GET and POST alike under one name.

What you will be able to do

  • Choose between path() and re_path() for a given URL pattern
  • Include the same app twice under different prefixes using namespace=
  • Choose between reverse() and reverse_lazy() based on when the code runs
  • Structure a versioned API with independent urls.py modules per version

Patterns and namespaces

Matching with a regex when path() isn't enough, and disambiguating an app included more than once.

re_path() and regex patterns

standardintermediate

re_path() matches a URL with a regular expression instead of path()'s converter syntax — named groups ((?P<name>...)) capture values, but every captured value arrives as a plain string, unlike path()'s automatic int/slug/uuid conversion.

Think of it as

path() is a fill-in-the-blank form with typed fields; re_path() is a blank sheet of paper where you write the whole pattern yourself. re_path() can express anything a regex can, including patterns path() has no converter for — but it gives nothing back for free: every captured group is a string, and getting the pattern wrong fails silently by just not matching, not by raising an error.

python
from django.urls import re_path

urlpatterns = [
    re_path(r"^articles/(?P<year>[0-9]{4})/$", views.year_archive, name="year-archive"),
]

What we're doing: Match a URL pattern path() cannot express directly — two segments that must both be four-digit years in a specific order.

urls.pypython
from django.urls import re_path
from . import views

urlpatterns = [
    re_path(
        r"^reports/(?P<start_year>[0-9]{4})-(?P<end_year>[0-9]{4})/$",
        views.date_range_report,
        name="date-range-report",
    ),
]
4
Both start_year and end_year are captured as strings and passed as keyword arguments to date_range_report — the view itself is responsible for converting and validating them as integers.

Why this works: No single path() converter can express "two four-digit numbers separated by a literal hyphen" as one segment — path()'s converters each match one segment in isolation, while a regex can constrain the whole pattern, hyphen included, in one expression.

Forgetting that re_path() captures always arrive as strings

Wrong

python
def year_archive(request, year):
    # year is "2024", a str — this comparison is always False
    if year > 2000:
        ...

Better

python
def year_archive(request, year):
    year = int(year)  # explicit conversion required
    if year > 2000:
        ...

What you see: A comparison or arithmetic operation against the captured value either raises a TypeError or, for a string-vs-int comparison, silently produces the wrong boolean result.

Why: re_path() has no equivalent of path()'s <int:name> converter — every named group it captures is handed to the view as a plain str, regardless of what the regex matched, so any numeric use requires an explicit int()/conversion the view must do itself.

path() vs re_path() for the same route

path() vs re_path() for the same route
Aspectpath()re_path()
Syntax<int:year>(?P<year>[0-9]{4})
Captured typeint (converted)str (always)
Readable by non-regex usersyesno
Expresses arbitrary patternsonly via a custom converteryes, directly

Together

python
from django.urls import path, re_path

urlpatterns = [
    path("articles/<int:year>/", views.year_archive),
    re_path(r"^articles/(?P<year>[0-9]{4})/(?P<slug>[\w-]+)/$", views.detail),
]

Remember: re_path() matches with a regex and captures everything as a string — reach for it only when path()'s five converters genuinely can't express the pattern.

See also: url configuration · url namespaces

URL namespaces

coreintermediate

app_name in an app's urls.py declares its application namespace; include()'s namespace= argument sets an instance namespace when the same app is included more than once. Together they let "polls:index" resolve unambiguously even if two different "polls" URLconfs are both installed.

Think of it as

app_name is an app's own last name, declared once. namespace= is a first name given at the door when it's let into a specific building — most apps only ever answer to their last name (app_name alone, when included once), but an app included twice under two different prefixes (author-polls/, publisher-polls/) needs the first name too, or 'polls:index' can't tell which one you mean.

python
reverse("polls:detail", kwargs={"pk": 5})
# in a template:
# {% url 'polls:detail' pk=poll.id %}

What we're doing: Include the same reusable app twice under different prefixes, and reverse() each instance's URLs unambiguously.

mysite/urls.pypython
from django.urls import include, path

urlpatterns = [
    path("author-polls/", include("polls.urls", namespace="author-polls")),
    path("publisher-polls/", include("polls.urls", namespace="publisher-polls")),
]

# reverse("author-polls:index")     -> "/author-polls/"
# reverse("publisher-polls:index")  -> "/publisher-polls/"
4
namespace="author-polls" overrides polls' own app_name ("polls") for this specific include — reverse() must now use "author-polls:index", not "polls:index", to reach this instance.
5
The same polls.urls module is reused unchanged for a second instance — namespace= is what makes the two installations individually addressable.

Why this works: A reusable app is written once but a project may need to mount it more than once (e.g. the same review-comments app under both /articles/ and /products/) — namespaces are what let reverse() and {% url %} target one specific mounted instance instead of ambiguously matching whichever was registered last.

Including the same app twice without setting namespace=

Wrong

python
urlpatterns = [
    path("author-polls/", include("polls.urls")),      # both default
    path("publisher-polls/", include("polls.urls")),   # to app_name "polls"
]
# reverse("polls:index") — which one does this mean?

Better

python
urlpatterns = [
    path("author-polls/", include("polls.urls", namespace="author-polls")),
    path("publisher-polls/", include("polls.urls", namespace="publisher-polls")),
]

What you see: django.urls.exceptions.NoReverseMatch, or reverse() silently resolving to whichever instance was registered — inconsistent, hard-to-predict URLs.

Why: Without an explicit namespace=, both includes fall back to the app's own app_name ("polls") as their instance namespace — Django cannot tell the two apart, since namespace uniqueness per include() is exactly what disambiguates otherwise-identical URLconfs.

The same app, mounted twice, disambiguated by namespace

polls/urls.py

app_name = "polls"

declared once, in the app itself

author-polls/

namespace="author-polls"

reverse("author-polls:index")

publisher-polls/

namespace="publisher-polls"

reverse("publisher-polls:index")

  • polls/urls.py
    • app_name = "polls" — declared once, in the app itself
  • author-polls/
    • namespace="author-polls" — reverse("author-polls:index")
  • publisher-polls/
    • namespace="publisher-polls" — reverse("publisher-polls:index")

app_name vs. include()'s namespace=

app_name vs. include()'s namespace=
PieceSet inPurpose
app_namethe app's own urls.pydeclares the app's default namespace
namespace=include() in the including urls.pyoverrides the instance namespace for THIS include
"app_name:view_name"reverse()/{% url %}targets the single/default instance
"instance_ns:view_name"reverse()/{% url %}targets a specific instance when included more than once

Together

python
# polls/urls.py
app_name = "polls"
urlpatterns = [path("", views.IndexView.as_view(), name="index")]

# project urls.py
urlpatterns = [
    path("author-polls/", include("polls.urls", namespace="author-polls")),
    path("publisher-polls/", include("polls.urls", namespace="publisher-polls")),
]

Remember: app_name declares an app's namespace once; include()'s namespace= sets a specific instance's namespace — required whenever the same app is included more than once.

See also: url configuration · reverse and reverse lazy

Advertisement

Reversing URLs and conventions

Turning a URL name back into a path, and how to version and name routes consistently.

reverse() and reverse_lazy()

coreintermediate

reverse("name", kwargs={...}) turns a URL name back into a real path string, immediately. reverse_lazy() does the same but defers resolution until the value is actually accessed — required in places evaluated at import/class-definition time, like a class-based view's success_url.

Think of it as

reverse() is a phone lookup you do right now, mid-conversation. reverse_lazy() is writing the person's NAME down and looking up the number only when you actually dial — because at class-definition time, urls.py might not have finished loading yet, so looking up the number that early could fail. Both return the same string eventually; they differ only in WHEN the lookup happens.

python
reverse("articles:year-archive", kwargs={"year": 2012})
# -> "/articles/2012/"
reverse("articles:year-archive", args=(2012,))
# -> "/articles/2012/" — same result, positional

What we're doing: Redirect to a named URL after a successful form submission, building the URL from reverse() rather than a hard-coded string.

billing/views.pypython
from django.urls import reverse
from django.shortcuts import redirect

def create_invoice(request):
    invoice = Invoice.objects.create(...)
    return redirect(reverse("billing:invoice-detail", kwargs={"pk": invoice.pk}))
5
reverse() runs at request time, well after urls.py has fully loaded, so it can safely resolve immediately — this is the ordinary case reverse() (not reverse_lazy()) is meant for.

Why this works: Building the redirect target from reverse() rather than a literal string like "/billing/invoices/5/" means the URL pattern can change in urls.py without this view needing to change at all — the name "billing:invoice-detail" is the only thing this code depends on.

Using reverse() instead of reverse_lazy() as a class attribute

Wrong

python
from django.urls import reverse
from django.views.generic import UpdateView

class ArticleUpdateView(UpdateView):
    model = Article
    success_url = reverse("articles:list")   # evaluated at class-definition time

Better

python
from django.urls import reverse_lazy
from django.views.generic import UpdateView

class ArticleUpdateView(UpdateView):
    model = Article
    success_url = reverse_lazy("articles:list")   # resolved only when accessed

What you see: django.urls.exceptions.NoReverseMatch raised at import time (often during Django's own startup, since views.py is imported early) — the app can fail to even start.

Why: A class body executes at module import time, which can happen before the project's URLconf has finished loading — reverse() demands an answer immediately and has nothing to resolve against yet. reverse_lazy() instead returns a proxy that only performs the actual lookup the first time the value is used, by which point urls.py is guaranteed to be ready.

When each one is safe to use

reverse()

  • +Resolves immediately, when called
  • +Safe inside a view or function body — urls.py is already loaded
  • +Fails immediately if urlconf isn't loaded yet

reverse_lazy()

  • Returns a lazy proxy — resolves on first actual use
  • Required for a class attribute (success_url), a decorator, settings.py
  • Safe even before urls.py has finished loading
  • reverse()
    • Resolves immediately, when called
    • Safe inside a view or function body — urls.py is already loaded
    • Fails immediately if urlconf isn't loaded yet
  • reverse_lazy()
    • Returns a lazy proxy — resolves on first actual use
    • Required for a class attribute (success_url), a decorator, settings.py
    • Safe even before urls.py has finished loading

reverse() vs reverse_lazy()

reverse() vs reverse_lazy()
Aspectreverse()reverse_lazy()
Resolvesimmediately, when calledlazily, on first actual use
Returnsa plain stra lazy string-like proxy object
Use insidea view, a function bodya class attribute, a decorator, settings.py
Fails if urlconf not loaded yetyes, immediatelyno — deferred past that point

Together

python
from django.urls import reverse, reverse_lazy
from django.views.generic import UpdateView

def my_view(request):
    return redirect(reverse("polls:index"))   # runs at request time — reverse() is fine

class ArticleUpdateView(UpdateView):
    success_url = reverse_lazy("articles:list")   # class body runs at import time

Remember: reverse() resolves immediately — use it in views and functions. reverse_lazy() defers resolution — use it anywhere evaluated at import/class-definition time (success_url, settings.py, decorators).

See also: url namespaces · url configuration

Versioned URLs and naming conventions

standardintermediate

URL-based API versioning puts the version in the path itself (/api/v1/orders/), included via a separate urls.py per version; naming conventions (prefixing a URL name with the app, e.g. "polls-detail" instead of "detail") avoid collisions when many apps define similarly-named views.

Think of it as

A versioned URL prefix is a fork in the road decided once, at include() — everything under /api/v1/ can evolve independently of /api/v2/, including having entirely different serializers or views behind the same-looking name. A naming convention is just discipline: two apps both wanting to call their list view "list" is fine as long as namespaces or a prefix keep "polls:list" and "billing:list" from ever being confused.

python
path("orders/<int:pk>/", views.OrderDetailView.as_view(), name="order-detail")
path("orders/<int:pk>/cancel/", views.OrderCancelView.as_view(), name="order-cancel")

What we're doing: Structure a versioned API so v1 and v2 can each evolve independently, sharing nothing but the project-level prefix.

mysite/urls.pypython
from django.urls import include, path

urlpatterns = [
    path("api/v1/", include("api.v1.urls")),
    path("api/v2/", include("api.v2.urls")),
]

# api/v1/urls.py and api/v2/urls.py are two entirely separate
# modules — v2 can change a serializer's shape without touching v1 at all
3
api.v1.urls and api.v2.urls are separate Python packages, not the same urls.py reused with a flag — this is what lets a breaking change land in v2 only.
4
Existing clients hard-coded to /api/v1/ keep working unchanged for as long as that urls.py (and the views/serializers behind it) is kept in place.

Why this works: Baking the version into the URL path, backed by physically separate urls.py modules per version, means a breaking API change never has to be a breaking change for existing clients — v1 keeps serving exactly what it always served while v2 is developed and rolled out alongside it.

Naming a URL after its HTTP method instead of its resource and action

Wrong

python
path("orders/<int:pk>/", views.get_order, name="get-order"),
path("orders/<int:pk>/update/", views.post_order_update, name="post-order-update"),

Better

python
path("orders/<int:pk>/", views.OrderDetailView.as_view(), name="order-detail"),
# same URL, same name, handles GET and PATCH/PUT via the view's own dispatch

What you see: Two URL names ("get-order", "post-order-update") exist for what is really one resource, and adding DELETE support means inventing a third name for the same path.

Why: A URL name should identify a resource and what happens to it, not which HTTP verb was used to get there — Django already routes by both path AND method inside a single view (see dispatch()), so baking the verb into the name creates one name per verb instead of one name per resource, and reverse() calls end up needing to know an implementation detail (which verb) that shouldn't matter to the caller.

Versioning approaches, in order of visibility

Versioning approaches, in order of visibility
ApproachExampleTrade-off
URL path/api/v1/orders/most visible, cacheable, but couples the version to routing
Query parameter/api/orders/?version=1easy to default, easy to forget to pass
Custom headerAccept: application/vnd.myapi.v1+jsoninvisible in URLs/logs, harder to test by hand

Together

python
# project urls.py
urlpatterns = [
    path("api/v1/", include("api.v1.urls")),
    path("api/v2/", include("api.v2.urls")),
]

Remember: Version an API by path prefix, backed by separate urls.py per version; name a URL after the resource and action ("order-detail"), never the HTTP method.

See also: url namespaces · url configuration

Advertisement