URLs & Routing

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1 on PyPI; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15+, checked on 2026-10-03; where Django 5.2 LTS behaves differently the text says so. Every listing was run in a scratch project on Python 3.13.16 and Django 6.1.1 with SQLite: manage.py check, 26 tests (manage.py test), a shell session and a curl session against runserver with DEBUG = False passed, and the output shown is the real output. None of the code needs PostgreSQL, Redis or any package beyond Django.

A URLconf is the table of contents of a Django site: a Python module whose urlpatterns list maps URL paths to views, and, because the same table can be read backwards, it is also the only place that knows what a URL looks like. This page covers both directions. Going forward it explains path() and re_path(), the converters that turn text from the URL into Python values, include() and the application and instance namespaces that make URLs from different apps coexist, and the error handlers that answer when nothing matches. Going backward it explains reverse(), reverse_lazy(), get_absolute_url() and the {% url %} template tag, so that no URL is ever typed twice. It closes with the checks Django runs on your URLconf, per-request URLconfs, translated URLs and a step-by-step migration from the long-removed url() function that most older tutorials still use. The views that the patterns point at are covered on the pages about function-based views, class-based views and generic views; where the URLconf sits in the whole request is on the request/response cycle page; the project layout that holds config/urls.py and catalog/urls.py is on Project Structure & Apps; and the first path() you ever write is on Getting Started.

Where the URLconf sits in the request/response cycle

Routing is the step between the middleware chain and the view. When the handler (WSGI or ASGI) receives a request it builds an HttpRequest, runs it down through the middleware listed in MIDDLEWARE, and only then, inside the innermost layer, resolves the path. Django’s documentation lists the algorithm exactly:

  1. Django picks the root URLconf module. Normally this is the ROOT_URLCONF setting, but if the HttpRequest has a urlconf attribute (set by a middleware that ran earlier) its value is used instead.

  2. Django imports that module and reads its urlpatterns variable, a sequence of path() and/or re_path() instances (and include() results).

  3. It runs through the patterns in order and stops at the first one that matches request.path_info, the path without the host, the query string or any script prefix.

  4. It imports and calls the view with the HttpRequest as the first argument, plus the captured values as keyword arguments (or positional arguments, if the pattern used unnamed regular-expression groups), overridden by the optional kwargs dictionary of the pattern.

  5. If no pattern matches, or any exception is raised on the way, Django calls an error-handling view.

flowchart TD req(["HttpRequest from the WSGI or ASGI handler"]) --> mw["Middleware chain, outer half"] mw --> sel{"request.urlconf set?"} sel -->|"yes"| custom["Resolver for request.urlconf"] sel -->|"no"| root["Resolver for ROOT_URLCONF"] custom --> res["resolver.resolve of request.path_info"] root --> res res -->|"first pattern that matches"| rm["request.resolver_match is set
a ResolverMatch with func, args, kwargs"] res -->|"no match: Resolver404"| h404["handler404 through the exception layer"] rm --> pv["process_view hooks of the middleware"] pv --> view["View: func(request, *args, **kwargs)"] view -->|"returns a response"| resp(["HttpResponse back up the middleware"]) view -->|"Http404, PermissionDenied, other exception"| err["handler404, handler403, handler400, handler500"] h404 --> resp err --> resp

Four consequences follow from this picture and come back in the sections below:

  • The URL is matched on the path alone. The query string, the domain and the HTTP method play no part (GET /books/ and POST /books/ reach the same view); the view or a decorator decides what to do with the method.

  • request.resolver_match exists only after resolution. It is available in views, in process_view() and later hooks, and in template contexts, but not in the "before the view" part of a middleware’s call, which runs first.

  • The resolver is built once. get_resolver() is cached per URLconf module, and the regular expressions of the patterns are compiled the first time they are needed, so routing cost per request is a sequential scan of compiled patterns, not an import.

  • Resolution is synchronous and CPU-only. The ASGI handler calls it directly in the event loop; it does no I/O, so an async view is routed exactly like a sync one (see Sync and async notes).

The Bookshelf URL design used on this page

Every example belongs to the Bookshelf catalogue, an app named catalog in a project named config. The model is small, because what matters here is the URL design. get_absolute_url() is the canonical way to give a model instance an address and it already uses reverse() (explained in reverse() and reverse_lazy()):

catalog/models.py
from django.db import models
from django.urls import reverse


class Book(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    isbn = models.CharField(max_length=13, unique=True)

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse("catalog:book-detail", kwargs={"slug": self.slug})

The app owns its routes. Its urls.py declares app_name = "catalog" (the application namespace), registers two custom converters (Custom converters) and lists the routes that the rest of the page introduces one at a time. Read it once now; every line is explained below:

catalog/urls.py
from django.urls import path, re_path, register_converter
from django.views.generic import RedirectView

from . import converters, views

register_converter(converters.FourDigitYearConverter, "yyyy")
register_converter(converters.Isbn13Converter, "isbn13")

app_name = "catalog"  # application namespace

urlpatterns = [
    path("", views.book_list, name="book-list"),
    path("books/new/", views.BookCreate.as_view(), name="book-create"),
    path("books/<slug:slug>/", views.book_detail, name="book-detail"),
    path("isbn/<isbn13:isbn>/", views.echo, name="by-isbn"),
    path("published/<yyyy:year>/", views.echo, name="by-year"),
    path("published/<yyyy:year>/<int:month>/", views.echo, name="by-month"),
    path("files/<path:file_path>", views.echo, name="file"),
    path("reviews/<uuid:review_id>/", views.echo, name="review"),
    path("about/", views.echo, {"template": "about"}, name="about"),
    path("page/", views.page, name="page-first"),
    path("page/<int:num>/", views.page, name="page"),
    path("search/", views.search_redirect, name="search"),
    # re_path: values arrive as str, and a non-capturing group makes a segment optional
    re_path(r"^archive/(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$", views.echo, name="archive"),
    re_path(r"^comments/(?:page-(?P<page_number>[0-9]+)/)?$", views.echo, name="comments"),
    # an old URL scheme keeps working: redirect permanently, rebuilding the target from the pattern name
    path("old-books/<slug:slug>/", RedirectView.as_view(pattern_name="catalog:book-detail", permanent=True)),
    path("ping/", views.ping, name="ping"),
    path("boom/", views.boom),
    path("forbidden/", views.forbidden),
    path("missing/", views.missing),
]

The project’s root URLconf includes it three times (Application and instance namespaces explains why), hangs a small versioned API and Django’s authentication URLs beside it, and names the four error handlers:

config/urls.py
from django.contrib import admin
from django.urls import include, path

handler400 = "catalog.error_views.bad_request"
handler403 = "catalog.error_views.permission_denied"
handler404 = "catalog.error_views.not_found"
handler500 = "catalog.error_views.server_error"

urlpatterns = [
    path("admin/", admin.site.urls),
    path("accounts/", include("django.contrib.auth.urls")),
    path("api/", include("config.api_urls")),
    # one URLconf, two instances: same app_name, different instance namespaces
    path("shop/", include("catalog.urls", namespace="shop")),
    path("outlet/", include("catalog.urls", namespace="outlet")),
    # the default instance: its instance namespace equals the application namespace
    path("", include("catalog.urls")),
]
config/api_urls.py
from django.urls import include, path

from catalog import views

app_name = "api"

urlpatterns = [
    path("books/", views.echo, name="book-list"),
    path("v1/", include("config.api_v1_urls")),
]
config/api_v1_urls.py
from django.urls import path

from catalog import views

app_name = "v1"

urlpatterns = [
    path("books/", views.echo, name="book-list"),
]

Most routes point at one deliberately simple view, echo, which returns what the dispatcher handed over (names, values and Python types) as JSON. That makes the behaviour of every pattern observable with curl; real views are explained on the views pages:

catalog/views.py
from django.core.exceptions import PermissionDenied
from django.http import Http404, HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.urls import reverse, reverse_lazy
from django.views.generic import CreateView

from .models import Book


def book_list(request):
    return render(request, "catalog/book_list.html", {"books": Book.objects.order_by("title")})


def book_detail(request, slug):
    book = get_object_or_404(Book, slug=slug)
    return render(request, "catalog/book_detail.html", {"book": book})


def echo(request, *args, **kwargs):
    """Show what the URL dispatcher handed to the view: names, values and Python types."""
    match = request.resolver_match
    return JsonResponse(
        {
            "args": [[repr(a), type(a).__name__] for a in args],
            "kwargs": {k: [repr(v), type(v).__name__] for k, v in kwargs.items()},
            "view_name": match.view_name,
            "route": str(match.route),
            "app_names": match.app_names,
            "namespaces": match.namespaces,
        }
    )


def page(request, num=1):
    return JsonResponse({"page": num, "type": type(num).__name__})


def search_redirect(request):
    """Redirect to the list with a query string and a fragment (reverse(query=, fragment=), Django 5.2+)."""
    url = reverse("catalog:book-list", query={"q": request.GET.get("q", ""), "sort": "title"}, fragment="results")
    return HttpResponseRedirect(url)


class BookCreate(CreateView):
    model = Book
    fields = ["title", "slug", "isbn"]  # explicit fields, never "__all__"
    template_name = "catalog/book_form.html"
    # reverse() here would fail at import time: the URLconf imports this module. reverse_lazy waits.
    success_url = reverse_lazy("catalog:book-list")


def boom(request):
    raise RuntimeError("a bug in the view")


def forbidden(request):
    raise PermissionDenied("members only")


def missing(request):
    raise Http404("no such book")


async def ping(request):
    """Async views are routed exactly like sync ones; resolution itself is synchronous and CPU-only."""
    return JsonResponse({"pong": True, "resolved": request.resolver_match.view_name})

path(): routes, kwargs and name

path(route, view, kwargs=None, name=None) returns one element of urlpatterns.

  • route is a string (or a gettext_lazy() string, see Translated and language-prefixed URLs) containing the pattern. Angle brackets capture a part of the URL and pass it to the view as a keyword argument (<slug:slug>); the part before the colon is the converter, which limits what matches and what Python type the view receives. Without a converter the capture is str and matches any non-empty text except /.

  • view is a function, the result of View.as_view() for class-based views, or an include(). Passing the class itself (a frequent slip: views.BookCreate instead of views.BookCreate.as_view()) is caught by the system check urls.E009.

  • kwargs is a dictionary of extra keyword arguments added to every call (Extra options and default arguments).

  • name makes the pattern reversible (Naming URL patterns).

Some rules are easy to miss and cause real bugs:

  • No leading slash. Every URL has one, so you write books/, never /books/ (system check urls.W002).

  • Order matters: the first match wins. In catalog/urls.py books/new/ comes before books/<slug:slug>/; swap them and the create page becomes a book with the slug new, which get_object_or_404 turns into a 404. Put specific literals before generic captures.

  • Trailing slashes are part of the pattern. books/ does not match /books (Trailing slashes and APPEND_SLASH). Pick a convention (Django’s is a trailing slash on pages, none on file-like URLs such as sitemap.xml or robots.txt) and keep to it.

  • The method, the query string and the host are not matched. https://example.com/books/?page=3 is looked up as books/.

  • Patterns are matched against path_info. Behind a proxy that mounts the project under a prefix, the prefix is the script prefix and is not part of the route (The script prefix and mounting under a sub-path).

The requests below were run against the project above. The tests assert the same facts without a server, using the test client and resolve():

catalog/tests.py: resolving, extra kwargs, ordering and the trailing slash
class ResolveTests(SimpleTestCase):
    def test_converters_pass_typed_values(self):
        body = self.client.get("/published/2024/3/").json()
        self.assertEqual(body["kwargs"], {"year": ["2024", "int"], "month": ["3", "int"]})
        review = "075194d3-6885-417e-a8a8-6c931e272f00"
        body = self.client.get(f"/reviews/{review}/").json()
        self.assertEqual(body["kwargs"]["review_id"], [repr(UUID(review)), "UUID"])

    def test_path_converter_keeps_slashes(self):
        body = self.client.get("/files/covers/2024/dune.png").json()
        self.assertEqual(body["kwargs"]["file_path"], ["'covers/2024/dune.png'", "str"])

    def test_re_path_passes_strings_and_optional_group(self):
        body = self.client.get("/archive/2024/03/").json()
        self.assertEqual(body["kwargs"], {"year": ["'2024'", "str"], "month": ["'03'", "str"]})
        # an optional group that did not match is not passed at all: the view needs a default
        self.assertEqual(self.client.get("/comments/").json()["kwargs"], {})
        self.assertEqual(self.client.get("/comments/page-2/").json()["kwargs"], {"page_number": ["'2'", "str"]})

    def test_extra_kwargs_and_defaults(self):
        self.assertEqual(self.client.get("/about/").json()["kwargs"], {"template": ["'about'", "str"]})
        self.assertEqual(self.client.get("/page/").json(), {"page": 1, "type": "int"})
        self.assertEqual(self.client.get("/page/3/").json(), {"page": 3, "type": "int"})

    def test_converter_to_python_value_error_means_no_match(self):
        self.assertEqual(self.client.get("/isbn/9780306406157/").status_code, 200)
        self.assertEqual(self.client.get("/isbn/9780306406158/").status_code, 404)  # bad check digit
        with self.assertRaises(Resolver404):
            resolve("/isbn/9780306406158/")

    def test_first_match_wins_and_trailing_slash(self):
        self.assertEqual(self.client.get("/books/new/").status_code, 200)  # before <slug:slug>
        response = self.client.get("/about")  # APPEND_SLASH: CommonMiddleware redirects
        self.assertRedirects(response, "/about/", status_code=301, fetch_redirect_response=False)

    def test_resolver_match_attributes(self):
        match = resolve("/shop/published/2024/")
        self.assertEqual(match.view_name, "shop:by-year")
        self.assertEqual((match.app_name, match.namespace), ("catalog", "shop"))
        self.assertEqual(match.route, "shop/published/<yyyy:year>/")
        self.assertEqual(match.kwargs, {"year": 2024})
        func, args, kwargs = match  # a ResolverMatch unpacks to a triple
        self.assertEqual((args, kwargs), ((), {"year": 2024}))

Path converters

Converters make the common cases (numbers, slugs, UUIDs) readable and typed without a regular expression. Django ships five:

Converter Regular expression Python value Notes

str (default)

[^/]+

str

Any non-empty text excluding /. Used when you write <name> with no converter.

int

[0-9]+

int

Zero or any positive integer. No sign, no limit on digits, leading zeros accepted (007 becomes 7).

slug

[-a-zA-Z0-9_]+

str

ASCII letters, digits, hyphen and underscore. Non-ASCII slugs from allow_unicode=True do not match.

uuid

[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}

uuid.UUID

Dashes required and letters lowercase, so one UUID has exactly one URL.

path

.+

str

Any non-empty text including /: a whole sub-path such as covers/2024/dune.png. Put it last and give the route no trailing slash.

The curl session below shows the values and Python types that reach the view (the echo view prints repr(value) and the type name). Note that year and month arrive as int, not str:

$ curl -si 'localhost:8822/published/2024/3/'
HTTP/1.1 200 OK
{"args": [], "kwargs": {"year": ["2024", "int"], "month": ["3", "int"]}, "view_name": "catalog:by-month", "route": "published/<yyyy:year>/<int:month>/", "app_names": ["catalog"], "namespaces": ["catalog"]}

Converters have two jobs and one rule. They match (the regex), they convert (to_python() for the URL to the view, to_url() for reverse()), and ValueError raised from either one means "this pattern does not apply": an incoming URL falls through to the next pattern and finally to a 404, and a reverse lookup raises NoReverseMatch unless another pattern with the same name fits. Raising ValueError from to_url() has been supported since Django 3.1.

Custom converters

A converter is a plain class with a regex string attribute and the two methods. The Bookshelf registers two: a four-digit year, taken from the official documentation’s own example, and an ISBN-13 whose check digit is validated, so a mistyped ISBN is a 404 at routing time instead of a failed lookup inside the view:

catalog/converters.py
class FourDigitYearConverter:
    regex = "[0-9]{4}"

    def to_python(self, value):
        return int(value)

    def to_url(self, value):
        return "%04d" % value


class Isbn13Converter:
    """Matches 13 digits and rejects numbers whose check digit is wrong."""

    regex = "[0-9]{13}"

    @staticmethod
    def _valid(digits: str) -> bool:
        total = sum(int(d) * (1 if i % 2 == 0 else 3) for i, d in enumerate(digits[:12]))
        return (10 - total % 10) % 10 == int(digits[12])

    def to_python(self, value):
        if not self._valid(value):
            raise ValueError(value)  # no match: Django tries the next pattern, then 404
        return value

    def to_url(self, value):
        value = str(value)
        if len(value) != 13 or not value.isdigit() or not self._valid(value):
            raise ValueError(value)  # reverse() raises NoReverseMatch
        return value

register_converter(converter, type_name) takes the class (not an instance; Django instantiates it) and the name used inside angle brackets. Call it at import time of a URLconf module, as catalog/urls.py does above, so the name exists before any pattern uses it. Two details matter on Django 6.1:

  • Registering a name twice, or a name that is a built-in (int, str, …​), raises ValueError("Converter 'yyyy' is already registered."). Overriding converters was deprecated in Django 5.1 and became an error in 6.0; a test below pins this behaviour.

  • The regex is spliced into the pattern’s regular expression, so it must not contain capturing groups. Use (?:…​) if you need grouping.

Resolve, reverse, and the converter’s ValueError semantics are covered by these tests:

catalog/tests.py: converters, reversing and registration
class ReverseTests(SimpleTestCase):
    def test_args_kwargs_and_converters(self):
        self.assertEqual(reverse("catalog:book-detail", kwargs={"slug": "dune"}), "/books/dune/")
        self.assertEqual(reverse("catalog:by-year", args=[7]), "/published/0007/")  # to_url formats
        with self.assertRaises(ValueError):
            reverse("catalog:book-detail", args=["a"], kwargs={"slug": "a"})

    def test_converter_to_url_value_error_is_no_reverse_match(self):
        with self.assertRaises(NoReverseMatch):
            reverse("catalog:by-isbn", kwargs={"isbn": "9780306406158"})
        with self.assertRaises(NoReverseMatch):
            reverse("catalog:book-detail", kwargs={"slug": "not a slug"})

    def test_query_and_fragment(self):
        url = reverse("catalog:book-list", query={"q": "dune", "page": 2}, fragment="results")
        self.assertEqual(url, "/?q=dune&page=2#results")
        qd = QueryDict("color=blue&color=red")
        self.assertEqual(reverse("catalog:book-list", query=qd), "/?color=blue&color=red")
        self.assertEqual(reverse("catalog:book-list", query={"q": "a b&c"}), "/?q=a+b%26c")

    def test_namespaces_and_instances(self):
        self.assertEqual(reverse("shop:book-list"), "/shop/")
        self.assertEqual(reverse("outlet:book-list"), "/outlet/")
        self.assertEqual(reverse("catalog:book-list"), "/")  # default instance
        self.assertEqual(reverse("catalog:book-list", current_app="outlet"), "/outlet/")
        self.assertEqual(reverse("api:v1:book-list"), "/api/v1/books/")  # nested namespaces

    def test_redirect_view_rebuilds_the_url_from_the_pattern_name(self):
        response = self.client.get("/old-books/dune/")
        self.assertRedirects(response, "/books/dune/", status_code=301, fetch_redirect_response=False)

    def test_search_redirect_view(self):
        response = self.client.get("/search/", {"q": "dune"})
        self.assertRedirects(response, "/?q=dune&sort=title#results", fetch_redirect_response=False)


class ScriptPrefixAndConverterRegistrationTests(SimpleTestCase):
    def test_reverse_honours_the_script_prefix(self):
        # Behind a proxy that mounts the project under /bookshelf, the WSGI/ASGI handler
        # (or FORCE_SCRIPT_NAME) sets the prefix and reverse() adds it for you.
        old = get_script_prefix()
        try:
            set_script_prefix("/bookshelf/")
            self.assertEqual(reverse("catalog:book-detail", kwargs={"slug": "dune"}), "/bookshelf/books/dune/")
        finally:
            set_script_prefix(old)

    def test_registering_a_converter_name_twice_fails(self):
        from .converters import FourDigitYearConverter

        with self.assertRaisesMessage(ValueError, "already registered"):
            register_converter(FourDigitYearConverter, "yyyy")  # Django 6.0+: overriding is an error

Pitfalls specific to converters:

  • int accepts leading zeros and has no upper bound. /page/007/ resolves to the same view as /page/7/, and reverse() always gives /page/7/. Two URLs for one page is a duplicate-content problem for search engines; if it matters, use a stricter custom converter ([1-9][0-9]*).

  • slug is ASCII only. If your slugs contain accented characters, use str with a model lookup, or a custom converter with the regex you need.

  • Converters match text, not existence. <int:pk> matches /books/999999/ even if there is no such book; the 404 comes from the view (get_object_or_404), not the router.

re_path(): regular expressions

re_path(route, view, kwargs=None, name=None) is path() with a regular expression for the route, compatible with Python’s re module. It is the direct successor of the old url() (Migrating from url() to path() and re_path()). Use it when no combination of converters expresses what you need: optional segments, several alternatives in one pattern, matching a fixed number of digits without writing a converter, or legacy URLs that must keep working character for character.

  • Write the route as a raw string (r"…​") so that sequences like \d need no doubled backslash.

  • If the route ends in $, the whole of path_info must match (re.fullmatch() is used); otherwise the pattern matches a prefix, which is how include() prefixes work with re_path().

  • Named groups (?P<name>…​) become keyword arguments; unnamed groups (…​) become positional arguments. Mixing both styles in one pattern is a bug waiting to happen: when both appear, the unnamed groups are ignored.

  • All values arrive as strings. There is no type conversion with re_path(). Switching a pattern between path() and re_path() changes the type of the view’s arguments, so adapt the view.

  • Use non-capturing groups (?:…​) for structure that the view does not need. Nested capturing groups pass the outer and the inner match to the view and, worse, force you to reverse with a piece of URL.

The script below builds a throw-away URLconf and prints what resolve() and reverse() do with each style:

ex/re_path_demo.py (run with python manage.py shell -v0 < ex/re_path_demo.py)
import types

from django.urls import NoReverseMatch, path, resolve, re_path, reverse

from catalog import views

urls = types.ModuleType("demo_urls")  # a throw-away URLconf, passed to resolve() and reverse()
urls.urlpatterns = [
    re_path(r"^legacy/([0-9]{4})/$", views.echo),                         # unnamed group -> positional
    re_path(r"^mixed/(\d+)/(?P<slug>[a-z]+)/$", views.echo),              # mixed -> unnamed ignored
    re_path(r"^blog/(page-([0-9]+)/)?$", views.echo, name="blog"),        # nested capture: bad
    re_path(r"^comments/(?:page-(?P<page_number>[0-9]+)/)?$", views.echo, name="comments"),  # good
    re_path(r"^kind/(?P<kind>books|authors)/$", views.echo, name="kind"),  # alternation inside a group
    re_path(r"^(?:books|authors)/$", views.echo, name="either"),          # top-level alternation
    path("n/<int:n>/", views.echo),
]

for url in ["/legacy/2024/", "/mixed/5/dune/", "/blog/page-2/", "/blog/", "/comments/page-2/", "/kind/books/", "/n/007/"]:
    match = resolve(url, urlconf=urls)
    print(f"{url:<20} args={match.args!r:<22} kwargs={match.kwargs}")

print(reverse("kind", kwargs={"kind": "authors"}, urlconf=urls))
print(reverse("blog", args=["page-2/"], urlconf=urls))      # you must pass the OUTER group
print(reverse("comments", kwargs={"page_number": 2}, urlconf=urls), reverse("comments", urlconf=urls))
try:
    reverse("either", urlconf=urls)
except NoReverseMatch:
    print("either: NoReverseMatch (a top-level | cannot be reversed)")
/legacy/2024/        args=('2024',)              kwargs={}
/mixed/5/dune/       args=()                     kwargs={'slug': 'dune'}
/blog/page-2/        args=('page-2/', '2')       kwargs={}
/blog/               args=(None, None)           kwargs={}
/comments/page-2/    args=()                     kwargs={'page_number': '2'}
/kind/books/         args=()                     kwargs={'kind': 'books'}
/n/007/              args=()                     kwargs={'n': 7}
/kind/authors/
/blog/page-2/
/comments/page-2/ /comments/
either: NoReverseMatch (a top-level | cannot be reversed)

Reading the output:

  • /legacy/2024/ arrives as the positional argument '2024', a string.

  • In the mixed pattern only slug is passed; the unnamed (\d+) group is silently dropped.

  • The "bad" blog pattern passes ('page-2/', '2') and (None, None) when the optional part is absent, and it can only be reversed by passing the outer group (page-2/), a piece of URL rather than a page number.

  • The "good" comments pattern with (?:page-…​) passes only page_number='2', and when the optional part is missing the keyword argument is omitted altogether, not passed as None. A view behind an optional named group therefore needs a default value (def comments(request, page_number=1)). The echo view above printed an empty kwargs for /comments/ in the test suite for the same reason.

  • An alternation inside a named group ((?P<kind>books|authors)) reverses fine, because the group is replaced by the argument you give. An alternation at the top level of the pattern (^(?:books|authors)/$) cannot be reversed: reverse() raises NoReverseMatch. Django documents this restriction: patterns that you reverse must not contain choices with | outside a captured group.

  • /n/007/ matched path("n/<int:n>/") with n=7, the leading-zero duplicate URL mentioned above.

Extra options and default arguments

There are three ways to give a view values that are not in the URL, and each is visible in catalog/urls.py:

  • The kwargs dictionary (third positional argument of path()): path("about/", views.echo, {"template": "about"}, name="about") calls echo(request, template="about"). If a captured name and a dictionary key collide, the dictionary wins. The syndication framework uses this technique to hand options to its feed views.

  • Defaults on the view: page/ and page/<int:num>/ both point at views.page, whose signature is def page(request, num=1). The first pattern captures nothing, so the view’s default applies. Give the two patterns distinct names (page-first, page) or reverse them by argument count; Django also matches names by the number and names of the arguments you pass.

  • include() with extra options: path("blog/", include("inner"), {"blog_id": 3}) passes blog_id=3 to every pattern of the included module, whether or not the view accepts it. Use it only when you control all the views in the module.

catalog/tests.py: ResolverMatch.extra_kwargs is where the dictionary shows up
from django.urls import resolve

match = resolve("/about/")
print(match.kwargs, match.captured_kwargs, match.extra_kwargs)
# {'template': 'about'} {} {'template': 'about'}

include(): composing URLconfs

include() mounts another set of patterns under a prefix. Django chops off the part of the URL matched so far and hands the rest to the included patterns, so catalog/urls.py never needs to know whether it lives at /, /shop/ or /outlet/. The signatures are:

  • include(module, namespace=None): a dotted path to a URLconf module (or the module object itself). The application namespace is taken from the module’s app_name.

  • include(pattern_list): any iterable of path()/re_path() instances, for small inline groups.

  • include((pattern_list, app_namespace), namespace=None): a 2-tuple that carries the application namespace with the list.

Typical uses, taken from the official guide and from config/urls.py above:

from django.urls import include, path

from credit import views as credit_views

extra_patterns = [
    path("reports/", credit_views.report),
    path("reports/<int:id>/", credit_views.report),
    path("charge/", credit_views.charge),
]

urlpatterns = [
    path("help/", include("apps.help.urls")),         # a module, by dotted path
    path("credit/", include(extra_patterns)),         # an inline list
    # one prefix, many suffixes: state the common part once
    path(
        "<page_slug>-<page_id>/",
        include([path("history/", views.history), path("edit/", views.edit)]),
    ),
]

Rules for include():

  • Captured parameters flow down. path("<username>/blog/", include("foo.urls.blog")) passes username to every view of foo/urls/blog.py.

  • Never end the prefix route of an include() with $. The rest of the URL must still be matched by the included patterns, so a $ makes everything below it unreachable; system check urls.W001 warns about it (System checks for the URLconf).

  • A trailing slash on the prefix is conventional. include("catalog.urls") under path("shop", …​) would give /shopbooks/.

  • include() order is match order. path("", include("catalog.urls")) in config/urls.py is last on purpose: an empty prefix matches every URL, so anything placed after it that it can match is shadowed.

  • i18n_patterns() cannot be used inside an included URLconf, only in the root one (Translated and language-prefixed URLs).

  • Mount a project’s URLs in one place. Keep the include list in config/urls.py short and let each app own its patterns; this is the same boundary as the app boundary described on Project Structure & Apps.

include() also validates its arguments. Asking for an instance namespace without an application namespace is an error, which the scratch project reproduced as:

ImproperlyConfigured: Specifying a namespace in include() without providing an app_name is not supported. Set the app_name attribute in the
included module, or pass a 2-tuple containing the list of patterns and app_name instead.

Naming URL patterns

Reversing needs names. Any string is allowed (it is not a Python identifier), but follow these rules:

  • Give every pattern that you link to a name, and name patterns after what they are, not where they are: book-detail, book-create, not books-slug-view.

  • Avoid names that other apps might use. If two apps both call a pattern comment, reverse("comment") finds whichever is last in the project’s urlpatterns. Prefix with the app (catalog-comment) or, better, use a namespace (Application and instance namespaces).

  • Do not put a colon in a name. The colon is the namespace separator (system check urls.W003).

  • Reuse a name deliberately to override a view. Django’s own LoginView is found by reverse("login"); if you name your own login route login and include it after django.contrib.auth.urls, reverse() finds yours.

  • The same name can serve several patterns if they differ in their arguments. reverse() matches the name, the number of positional arguments or the names of the keyword arguments, and any converter that raises ValueError in to_url().

Application and instance namespaces

Namespaces let different apps (or several copies of one app) use the same URL names without clashing. A namespace has two parts, and both are strings:

  • Application namespace: the name of the app being deployed. Every instance of an app shares it. It comes from app_name in the included module or from the 2-tuple form of include(). Django’s admin has the application namespace admin.

  • Instance namespace: identifies one deployment of the app. It must be unique across the project. By default it equals the application namespace, which makes that instance the default instance; pass namespace="shop" to include() to give it another name.

A namespaced name is written app:name, and namespaces nest: api:v1:book-list is the pattern book-list in the namespace v1, itself inside api. The Bookshelf uses all of it. catalog/urls.py sets app_name = "catalog"; config/urls.py includes the module three times (instances shop, outlet, and the default instance catalog at the root), and api_urls.py includes api_v1_urls.py, each with its own app_name.

When Django reverses catalog:book-list it follows the documented strategy, which is worth knowing by heart because it explains most "why did the link go to the wrong place" surprises:

flowchart TD start(["reverse of app:name"]) --> appns{"app matches an
application namespace?"} appns -->|"no"| inst["Look up app as an instance namespace directly"] appns -->|"yes: a list of instances"| cur{"current_app given
or request.current_app set?"} cur -->|"yes"| use1["Use that instance, if it is one of the list"] cur -->|"no"| def{"An instance whose namespace
equals the application namespace?"} def -->|"yes"| use2["Use the default instance"] def -->|"no"| use3["Use the last deployed instance"] inst --> done(["Resolve the name inside the instance"]) use1 --> done use2 --> done use3 --> done

Applied to the Bookshelf’s config/urls.py, the observed behaviour is:

  • reverse("shop:book-list") and reverse("outlet:book-list") always go to that instance: /shop/ and /outlet/.

  • reverse("catalog:book-list") with no current instance goes to the default instance (instance namespace catalog): /. With only the shop and outlet includes (no default instance), the same call returns /outlet/, the last deployed instance (checked with a throw-away URLconf), which is the surprise that bites people who add a second instance later; current_app="shop" there returns /shop/.

  • reverse("catalog:book-list", current_app="outlet") goes to /outlet/. The current_app hint is how a page rendered inside the outlet instance keeps its links inside it.

  • The {% url %} tag takes the namespace of the currently resolved view (request.resolver_match.namespace) as the current app, unless you set request.current_app (The {% url %} template tag). In a class-based view, pass current_app=self.request.resolver_match.namespace to reverse() yourself.

catalog/tests.py: instance namespaces, the default instance and the url tag following the current instance
class BookUrlsTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.book = Book.objects.create(title="Dune", slug="dune", isbn="9780306406157")

    def test_get_absolute_url_and_template_url_tag(self):
        self.assertEqual(self.book.get_absolute_url(), "/books/dune/")
        html = self.client.get("/").content.decode()
        self.assertIn('href="/books/dune/"', html)
        self.assertIn('href="/isbn/9780306406157/"', html)
        self.assertIn('href="/books/new/"', html)
        self.assertIn("no optional page", html)  # {% url ... as var %} does not raise
        # {% url %} takes no query argument; {% querystring %} (Django 5.1+) appends one
        self.assertIn('href="/?page=2"', html)

    def test_url_tag_follows_the_current_instance(self):
        html = self.client.get("/shop/books/dune/").content.decode()
        self.assertIn("instance <code>shop</code>", html)
        self.assertIn('href="/shop/"', html)  # {% url 'catalog:book-list' %} stayed in "shop"
        html = self.client.get("/outlet/books/dune/").content.decode()
        self.assertIn('href="/outlet/"', html)

    def test_create_view_uses_reverse_lazy_success_url(self):
        response = self.client.post("/books/new/", {"title": "Emma", "slug": "emma", "isbn": "9780141439587"})
        self.assertRedirects(response, "/", fetch_redirect_response=False)

Pitfalls:

  • Duplicate instance namespaces. Two include() calls with the same namespace make one of them unreachable by name; system check urls.W005 warns ("URL namespace …​ isn’t unique").

  • A namespace without an app_name is an ImproperlyConfigured error since Django 2.0 (see above).

  • Reversing a namespaced view by its callable does not work: reverse(views.archive) only reverses non-namespaced patterns. Always reverse by name.

  • Do not hardcode instance names in reusable code. A reusable app should reverse its own names through its application namespace and let the project decide how many instances to deploy (that is what AdminSite instances do).

reverse() and reverse_lazy()

reverse(viewname, urlconf=None, args=None, kwargs=None, current_app=None, , query=None, fragment=None) returns the absolute *path (no scheme or host) for a URL pattern name. It is the Python-code counterpart of the {% url %} tag, and the reason a URL is written exactly once, in the URLconf.

  • viewname is a pattern name ("catalog:book-detail") or, discouraged, a callable view object from the URLconf. A view built with as_view() can be reversed by object only if the same object is used in the URLconf, and namespaced views cannot be reversed by object.

  • args or kwargs, never both: passing both raises ValueError.

  • current_app is the hint described in Application and instance namespaces.

  • urlconf names a different URLconf module to reverse against (default: the root URLconf of the current thread, which is request.urlconf if a middleware set one).

  • query (Django 5.2 and later, keyword-only) is added to the returned URL after a ?. It accepts a QueryDict (for example request.GET) or anything urllib.parse.urlencode() accepts: a dict, or a list of pairs for repeated keys. Values are encoded for you.

  • fragment (5.2 and later) is appended after #, without encoding.

  • The result is already URL-quoted. Do not run it through urllib.parse.quote() again.

  • If nothing matches, NoReverseMatch is raised. Its message lists every pattern that was tried.

The script below exercises all of it. On Django 5.1 or older, query= and fragment= raise TypeError; build the query string with urllib.parse.urlencode() there.

ex/reverse_demo.py (run with python manage.py shell -v0 < ex/reverse_demo.py)
from django.http import QueryDict
from django.urls import NoReverseMatch, resolve, reverse, reverse_lazy

print(reverse("catalog:book-detail", kwargs={"slug": "dune"}))
print(reverse("catalog:by-year", args=[7]))
print(reverse("catalog:book-list", query={"q": "dune", "page": 2}, fragment="results"))
print(reverse("catalog:book-list", query=[("tag", "sf"), ("tag", "classic"), ("none", None)]))
print(reverse("catalog:book-list", query=QueryDict("color=blue&color=red")))
print(reverse("catalog:book-list", query={"q": "a b&c"}, fragment="no encoding is done"))
print(reverse("catalog:file", kwargs={"file_path": "covers/é uno.png"}))
print(reverse("shop:book-list"), reverse("outlet:book-list"), reverse("catalog:book-list"))
print(reverse("catalog:book-list", current_app="outlet"))
print(reverse("api:v1:book-list"))
try:
    reverse("catalog:by-isbn", kwargs={"isbn": "9780306406158"})
except NoReverseMatch as exc:
    print("NoReverseMatch:", str(exc).splitlines()[0][:110])
try:
    reverse("catalog:book-detail", args=["dune"], kwargs={"slug": "dune"})
except ValueError as exc:
    print("ValueError:", exc)
lazy = reverse_lazy("catalog:book-list")
print(type(lazy).__name__, str(lazy))
match = resolve("/shop/published/2024/")
print(match)
print(match.view_name, match.app_name, match.namespace, match.route, match.captured_kwargs)
print(match.func.__name__, match.args, match.kwargs, match.url_name, match.extra_kwargs)
func, args, kwargs = match
print(kwargs)
/books/dune/
/published/0007/
/?q=dune&page=2#results
/?tag=sf&tag=classic&none=None
/?color=blue&color=red
/?q=a+b%26c#no encoding is done
/files/covers/%C3%A9%20uno.png
/shop/ /outlet/ /
/outlet/
/api/v1/books/
NoReverseMatch: Reverse for 'by-isbn' with keyword arguments '{'isbn': '9780306406158'}' not found. 1 pattern(s) tried: ['isbn
ValueError: Don't mix *args and **kwargs in call to reverse()!
__proxy__ /
ResolverMatch(func=catalog.views.echo, args=(), kwargs={'year': 2024}, url_name='by-year', app_names=['catalog'], namespaces=['shop'], route='shop/published/<yyyy:year>/', captured_kwargs={'year': 2024})
shop:by-year catalog shop shop/published/<yyyy:year>/ {'year': 2024}
echo () {'year': 2024} by-year {}
{'year': 2024}

What to read from the output:

  • to_url() formatted the year: reverse("catalog:by-year", args=[7]) is /published/0007/.

  • query with a list of pairs repeats keys (tag=sf&tag=classic); a None value becomes the text None (it is not skipped), so filter out missing values yourself. A QueryDict keeps repeated keys, which is what you want when you rebuild a filter URL from request.GET.

  • Spaces in query values become + and & becomes %26; the fragment is appended as is.

  • The path converter output was quoted (%C3%A9%20uno.png).

  • The NoReverseMatch for the invalid ISBN came from the converter’s to_url() raising ValueError.

  • reverse_lazy() returned a lazy proxy that behaves like the string.

reverse_lazy()

reverse_lazy() has the same signature but evaluates when the value is first used. It is needed whenever the URL is required before the URLconf can be loaded, which is typically at import time of a module that the URLconf itself imports:

  • the success_url attribute of a generic class-based view (a class body runs at import);

  • the login_url argument of a decorator such as permission_required;

  • a default value in a function signature.

BookCreate in catalog/views.py shows the first case, and the test test_create_view_uses_reverse_lazy_success_url above posts a form and follows the redirect. Calling plain reverse() in that class body would try to import config.urls while catalog.views is still being imported, a circular import. Inside a method, which runs per request, use reverse(); the lazy version only adds a proxy.

Redirecting after a POST

The idiomatic use of reverse() in a view is the Post/Redirect/Get redirect. search_redirect builds a URL with a query string and a fragment, and the redirect() shortcut accepts a pattern name, a model instance (through get_absolute_url()), or a URL directly:

from django.shortcuts import redirect

def archive_book(request, slug):
    ...                                   # change state on POST only
    return redirect("catalog:book-list")  # a pattern name, resolved with reverse()

def after_create(request, book):
    return redirect(book)                 # calls book.get_absolute_url()

The search_redirect view and its test (test_search_redirect_view) are in the listings above; the same URL came back from curl:

$ curl -si 'localhost:8822/search/?q=dune'
HTTP/1.1 302 Found
Location: /?q=dune&sort=title#results

resolve() and ResolverMatch

resolve(path, urlconf=None) does what the dispatcher does: it turns a path into a ResolverMatch, or raises Resolver404 (a subclass of Http404) when nothing matches. Use it in tests, in middleware that needs to know which view will run, and in tools. A ResolverMatch carries:

Attribute Meaning

func

The view callable (for a class-based view, the function returned by as_view(), which has a view_class attribute).

args, kwargs

The positional and keyword arguments the view will receive. kwargs is captured_kwargs plus extra_kwargs.

captured_kwargs, extra_kwargs

The values captured from the URL, and the values added by the kwargs dictionary of path()/include().

url_name

The name of the matched pattern.

route

The route of the matching pattern, for example shop/published/<yyyy:year>/.

app_name, app_names

The application namespace (and its components: foo:bar gives ['foo', 'bar']).

namespace, namespaces

The instance namespace and its components.

view_name

The name including the namespace, e.g. shop:by-year, or the dotted path of the view if the pattern has no name.

tried

The patterns tried before the match; useful in debugging.

A ResolverMatch unpacks as the triple (func, args, kwargs); the session in the reverse demo above printed one:

ResolverMatch(func=catalog.views.echo, args=(), kwargs={'year': 2024}, url_name='by-year', app_names=['catalog'], namespaces=['shop'], route='shop/published/<yyyy:year>/', captured_kwargs={'year': 2024})

During a request the same object is request.resolver_match; the echo view uses it to print view_name, route and the namespaces (see the curl output earlier on this page). It is how logging, analytics and CSS "active link" helpers know which route is running (request.resolver_match.url_name) without parsing the path.

Source: resolve() and ResolverMatch

The {% url %} template tag

In templates, {% url %} is reverse(): the first argument is the pattern name (a quoted literal or a context variable) and the rest are the arguments, positional or keyword but never mixed. Special characters in the result are encoded with iri_to_uri().

catalog/templates/catalog/book_list.html
<h1>Books</h1>
<ul>
{% for book in books %}
  <li><a href="{{ book.get_absolute_url }}">{{ book.title }}</a>
      (<a href="{% url 'catalog:by-isbn' isbn=book.isbn %}">ISBN</a>)</li>
{% empty %}
  <li>No books yet.</li>
{% endfor %}
</ul>
<p><a href="{% url 'catalog:book-create' %}">Add a book</a></p>
<p>Archive: <a href="{% url 'catalog:by-year' 2024 %}">2024</a></p>
{% url 'catalog:missing-page' as optional_url %}
{% if optional_url %}<a href="{{ optional_url }}">never rendered</a>{% else %}no optional page{% endif %}
<p>Next: <a href="{% url 'catalog:book-list' %}{% querystring page=2 %}">page 2</a></p>
  • {% url 'catalog:by-isbn' isbn=book.isbn %} uses keyword syntax; {% url 'catalog:by-year' 2024 %} uses positional syntax.

  • {% url 'catalog:missing-page' as optional_url %} stores the URL in a variable instead of printing it and, unlike the plain form, does not raise NoReverseMatch when the name does not exist; the variable is empty and {% if %} handles it. The variable is scoped to the enclosing {% block %}. This is how a reusable template links to an optional page.

  • A missing name without as var raises NoReverseMatch and the page becomes a 500 (a debug page with DEBUG = True). That is the intended fail-fast behaviour for a link you expect to exist.

  • The tag has no query or fragment argument. Append a query string with {% querystring %} (Django 5.1 and later), as in the last line of the template, or write ?q={{ term|urlencode }} by hand.

  • Always quote the name. {% url book-detail book.slug %} without quotes looks up a context variable named book-detail, which is a syntax or NoReverseMatch error at best and silently wrong at worst.

  • The namespace of the current view is used as the current app, so a template served by the shop instance links to /shop/, as the test test_url_tag_follows_the_current_instance verifies. It needs django.template.context_processors.request or a RequestContext (render() provides one). To override the choice, set request.current_app in the view before rendering.

  • For links to model instances prefer {{ book.get_absolute_url }}: the model, not each template, then decides what a book’s address is.

The detail template shows the instance namespace of the resolved URL:

catalog/templates/catalog/book_detail.html
<h1>{{ book.title }}</h1>
<p>You are in the instance <code>{{ request.resolver_match.namespace }}</code>.</p>
<p><a href="{% url 'catalog:book-list' %}">Back to the list</a></p>

Error handlers: handler400, handler403, handler404 and handler500

When no pattern matches, or a view raises, Django calls one of four error views. The defaults live in django.views.defaults and render 400.html, 403.html, 404.html and 500.html from the top of your template tree if you provide them (they are used when DEBUG is False; the 404 template receives request_path and exception, the 403 template receives exception, and the 400 and 500 templates receive no exception text on purpose, so that nothing is disclosed). Override a handler by assigning a callable or a dotted path to a module-level variable of the root URLconf; setting it in any other URLconf has no effect.

Variable Raised by Signature

handler400

SuspiciousOperation and its subclasses (bad host header, malformed multipart upload…​)

view(request, exception), returns HttpResponseBadRequest

handler403

PermissionDenied

view(request, exception), returns HttpResponseForbidden

handler404

Http404, including the Resolver404 of an unmatched URL

view(request, exception), returns HttpResponseNotFound

handler500

Any other exception raised by a view

view(request), no exception argument; returns HttpResponseServerError

Since Django 2.0, custom 400, 403 and 404 views that do not accept the exception argument are no longer supported (and the default handlers became callables instead of dotted paths); system checks urls.E007 and urls.E008 verify, at startup, that each custom handler can be imported and takes the right number of arguments. The Bookshelf’s handlers answer in JSON, which is what an API wants; an HTML site would render() a template instead:

catalog/error_views.py
from django.http import JsonResponse


def not_found(request, exception):
    # For an unmatched URL the exception is a Resolver404 whose first argument is a dict
    # ({"tried": [...], "path": ...}) describing your whole URLconf: never echo str(exception).
    message = exception.args[0] if exception.args and isinstance(exception.args[0], str) else ""
    return JsonResponse({"error": "not found", "path": request.path, "detail": message}, status=404)


def permission_denied(request, exception):
    return JsonResponse({"error": "forbidden"}, status=403)


def bad_request(request, exception):
    return JsonResponse({"error": "bad request"}, status=400)


def server_error(request):  # no `exception` argument: Django calls handler500(request)
    return JsonResponse({"error": "server error"}, status=500)
catalog/tests.py: the handlers, DEBUG and request.urlconf
@override_settings(DEBUG=False, ALLOWED_HOSTS=["testserver", "api.localhost"])
class ErrorHandlerTests(SimpleTestCase):
    def setUp(self):
        self.client.raise_request_exception = False  # let the client return the 500 response

    def test_custom_handlers(self):
        r = self.client.get("/nope/")
        self.assertEqual((r.status_code, r.json()["error"]), (404, "not found"))
        self.assertEqual(r.json(), {"error": "not found", "path": "/nope/", "detail": ""})  # no URLconf leak
        r = self.client.get("/missing/")
        self.assertEqual(r.json()["detail"], "no such book")
        r = self.client.get("/forbidden/")
        self.assertEqual((r.status_code, r.json()), (403, {"error": "forbidden"}))
        r = self.client.get("/boom/")
        self.assertEqual((r.status_code, r.json()), (500, {"error": "server error"}))

    @override_settings(DEBUG=True)
    def test_with_debug_true_the_technical_404_page_wins(self):
        r = self.client.get("/nope/")
        self.assertEqual(r.status_code, 404)
        self.assertIn("Page not found", r.content.decode())  # Django's debug page, not handler404

    def test_request_urlconf_selects_another_urlconf(self):
        self.assertEqual(self.client.get("/", headers={"host": "api.localhost"}).json()["view_name"], "api-root")
        self.assertEqual(self.client.get("/about/", headers={"host": "testserver"}).json()["view_name"], "catalog:about")

The curl session below ran against runserver with DEBUG = False; /boom/ raises RuntimeError, /forbidden/ raises PermissionDenied:

$ curl -si 'localhost:8822/nope/'
HTTP/1.1 404 Not Found
{"error": "not found", "path": "/nope/", "detail": ""}
$ curl -si 'localhost:8822/forbidden/'
HTTP/1.1 403 Forbidden
{"error": "forbidden"}
$ curl -si 'localhost:8822/boom/'
HTTP/1.1 500 Internal Server Error
{"error": "server error"}

Four details from reading the implementation and from the run:

  • With DEBUG = True, handler404 is not called. Django shows its technical 404 page (with the list of patterns tried). That is why the test above overrides DEBUG=False, and why "my custom 404 page never shows locally" is the most asked error-page question.

  • Never echo str(exception) for a URL that did not match. The exception is a Resolver404 whose first argument is a dictionary with the whole list of patterns tried; printing it leaks your URLconf structure (an earlier draft of this handler did exactly that and the curl output contained every route, including the admin prefix). The handler above only uses the message when it is a plain string (for example Http404("no such book")).

  • Keep handler500 trivial. It runs when something already failed; if it touches the database, the cache or context processors that caused the failure, it fails again. Render a static template or return a small response.

  • Test handlers by raising the exception in a test view, with @override_settings(ROOT_URLCONF=name) for a module that defines handler403 = …​, as the official documentation shows; Client.raise_request_exception = False lets the client return the 500 response instead of re-raising it.

For CSRF failures there is a separate setting, CSRF_FAILURE_VIEW, not a handler variable. Views that return an error response directly (HttpResponseNotFound, HttpResponse(status=201)) do not go through these handlers: the handlers are for exceptions.

Choosing the URLconf per request

request.urlconf, when set by middleware, replaces ROOT_URLCONF for that request, and setting it to None returns to the default. This is how a single Django process serves several hostnames with different routes (an api. subdomain, a "club" site, a staff site) without running several projects. HostUrlconfMiddleware serves config/urls_api_host.py for hosts that start with api.:

catalog/middleware.py
class HostUrlconfMiddleware:
    """Serve a different URLconf for api.* hosts by setting request.urlconf."""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        host = request.get_host().split(":")[0]
        if host.startswith("api."):
            request.urlconf = "config.urls_api_host"
        return self.get_response(request)
config/urls_api_host.py
from django.urls import path

from catalog import views

urlpatterns = [
    path("", views.echo, name="api-root"),
]

The test test_request_urlconf_selects_another_urlconf above sends two requests with different Host headers, and the real server answered the same way:

$ curl -s -H 'Host: api.localhost:8822' localhost:8822/
{"args": [], "kwargs": {}, "view_name": "api-root", "route": "", "app_names": [], "namespaces": []}

The middleware must be listed in MIDDLEWARE and must set request.urlconf before it calls get_response, because resolution happens inside the innermost layer. CommonMiddleware’s `APPEND_SLASH logic also consults request.urlconf when it decides whether a slash redirect is possible, so a middleware that switches URLconfs belongs before CommonMiddleware if the redirect must follow the switch. (The scratch project, whose host-specific URLconf has no slash-less routes, lists it right after CommonMiddleware.) Resolvers are cached per URLconf module, so switching between a handful of modules is cheap, and django.urls.set_urlconf()/get_urlconf() exist for the rare place that has to switch programmatically. clear_url_caches() is only needed in tests that modify a URLconf at runtime.

Trailing slashes and APPEND_SLASH

CommonMiddleware implements the APPEND_SLASH setting (default True): if a request path does not end in /, does not match any pattern, but would match once a slash is appended, the middleware answers with a permanent redirect (301) to the slash form. The test test_first_match_wins_and_trailing_slash and the real server agree:

$ curl -si 'localhost:8822/about'
HTTP/1.1 301 Moved Permanently
Location: /about/

Things to know:

  • A POST cannot be redirected without losing its body. With DEBUG = True, an unslashed POST, PUT, PATCH or DELETE that would need the redirect raises a RuntimeError explaining the problem, so that you notice in development. In production it silently becomes a 301 and the data is lost; point forms at the slashed URL (generate it with {% url %}).

  • Opt a view out with @no_append_slash from django.views.decorators.common (added in Django 3.2), for URLs that are intentionally slash-less, such as file-like endpoints.

  • The redirect only fires when the slash form exists. /nope stays a 404, not a redirect to /nope/.

  • Set APPEND_SLASH = False for an API whose clients must get a clean 404 for the wrong spelling.

  • Case counts. /Books/ does not match books/. Django does not normalise case; add a redirect or lower-case at the proxy if you need tolerance.

Redirecting old URLs with RedirectView

When you change a URL scheme, keep the old URLs alive with a permanent redirect. RedirectView builds the target from a pattern name, and passes the captured arguments on:

from django.views.generic import RedirectView

path("old-books/<slug:slug>/", RedirectView.as_view(pattern_name="catalog:book-detail", permanent=True)),

That line is in catalog/urls.py above, and the real server answered with a 301 and Location: /books/dune/:

$ curl -si 'localhost:8822/old-books/dune/'
HTTP/1.1 301 Moved Permanently
Location: /books/dune/

permanent=True answers 301 (browsers and search engines cache it), False answers 302. RedirectView also accepts url= and query_string=True; Django 6.1 added preserve_request, which makes the redirect keep the HTTP method and body (status 307 or 308 instead of 302 or 301), useful when an endpoint moves and its POST clients must keep working. The class-based view page covers RedirectView in full.

URLconfs that Django and other packages give you

Several pieces ship URLs that you include(); each has an app_name or a fixed set of names that other code expects:

  • The admin: path("admin/", admin.site.urls) (namespace admin; reverse("admin:index"), reverse("admin:app_list", kwargs={"app_label": "auth"})). Since Django 3.2 the admin installs a catch-all view that redirects unauthenticated visitors to the login page for any URL under its prefix, so a pattern routed after admin/ that also starts with admin/ becomes unreachable. Reorder, or set AdminSite.final_catch_all_view = False.

  • Authentication: path("accounts/", include("django.contrib.auth.urls")) adds login, logout, password_change and the password reset URLs under unnamespaced names (login, logout…​), which is why LOGIN_URL = "login" works. LogoutView accepts only POST since Django 5.0, so a logout link must be a small form.

  • Static and media files in development: static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) from django.conf.urls.static returns a pattern that serves files when DEBUG is true, and an empty list otherwise (and for prefixes that include a host). It is a development convenience, never a production file server (see the static-files and file-upload pages). STATIC_URL/MEDIA_URL must end with a slash (system check urls.E006).

  • Third-party and debug tools: django-debug-toolbar adds path("debug/", include("debug_toolbar.urls")); REST frameworks give you routers that generate a URLconf from a viewset; admin sites for different audiences are separate AdminSite instances, which is the instance-namespace feature at work.

Translated and language-prefixed URLs

i18n_patterns(urls, prefix_default_language=True) from django.conf.urls.i18n prepends the active language code to the patterns it wraps (/en/books/, /es/books/), reverse() and {% url %} produce the URL in the active language, and routes may be marked translatable with gettext_lazy() (path(_("books/"), …​)). It needs LocaleMiddleware, can only be used in the *root URLconf (using it in an included one raises ImproperlyConfigured), and prefix_default_language=False keeps existing URLs unprefixed for the default language. Translating a segment needs a message catalogue, which is the subject of the internationalization page; the test below checks the prefixing without one, and the unprefixed /books/ is redirected by the middleware:

config/urls_i18n.py
from django.conf.urls.i18n import i18n_patterns
from django.urls import path
from django.utils.translation import gettext_lazy as _

from catalog import views

urlpatterns = i18n_patterns(
    path(_("books/"), views.echo, name="books"),  # translated route: needs a catalogue to differ per language
    prefix_default_language=True,
)
catalog/tests.py: language prefixes and an async view
class AsyncAndI18nTests(SimpleTestCase):
    async def test_async_view_is_routed_like_any_other(self):
        response = await AsyncClient().get("/ping/")
        self.assertEqual(response.json(), {"pong": True, "resolved": "catalog:ping"})

    @override_settings(
        ROOT_URLCONF="config.urls_i18n",
        LANGUAGES=[("en", "English"), ("es", "Spanish")],
        LANGUAGE_CODE="en",
        MIDDLEWARE=["django.middleware.locale.LocaleMiddleware"],
    )
    def test_i18n_patterns_prefix_the_language(self):
        self.assertEqual(reverse("books"), "/en/books/")
        with translation.override("es"):
            self.assertEqual(reverse("books"), "/es/books/")
        self.assertEqual(self.client.get("/es/books/").status_code, 200)
        self.assertEqual(self.client.get("/books/").status_code, 302)  # LocaleMiddleware redirects to /en/books/

As the official documentation advises, use translated routes only inside an i18n_patterns() block, so that a carelessly translated segment cannot collide with an untranslated pattern.

The script prefix and mounting under a sub-path

If a proxy serves the project under a path such as /bookshelf/, the WSGI server passes that prefix as SCRIPT_NAME, Django stores it with set_script_prefix(), and reverse(), {% url %} and redirects add it automatically, while patterns keep matching path_info without it. FORCE_SCRIPT_NAME sets it explicitly. get_script_prefix() returns it (it is "/" when the project is at the root) and, as the documentation warns, it cannot be used outside a request/response cycle because it relies on values initialised then. The test test_reverse_honours_the_script_prefix above proves that reverse() adds /bookshelf/ when the prefix is set. Never concatenate the prefix by hand: use reverse() so that the same code works at / and under a sub-path.

System checks for the URLconf

manage.py check (and runserver, migrate and the test runner) validates the URLconf. These are the checks, with the exact messages that the scratch project printed for a deliberately broken URLconf:

config/urls_bad.py
from django.urls import include, path, re_path

from catalog import views

urlpatterns = [
    path("/books/", views.book_list, name="catalog:books"),       # urls.W002 and urls.W003
    re_path(r"^reports/$", include("catalog.urls")),               # urls.W001 is for include() with $
    re_path(r"^legacy/$", views.BookCreate),                       # urls.E009: forgot .as_view()
    path("year/<int:year/", views.echo),                           # urls.W010: unmatched angle bracket
    path("a/", include("catalog.urls", namespace="same")),
    path("b/", include("catalog.urls", namespace="same")),         # urls.W005: duplicate namespace
]
$ DJANGO_SETTINGS_MODULE=config.settings_bad python manage.py check
SystemCheckError: System check identified some issues:

ERRORS:
?: (urls.E009) Your URL pattern '^legacy/$' has an invalid view, pass BookCreate.as_view() instead of BookCreate.

WARNINGS:
?: (urls.W001) Your URL pattern '^reports/$' uses include with a route ending with a '$'. Remove the dollar from the route to avoid problems including URLs.
?: (urls.W002) Your URL pattern '/books/' [name='catalog:books'] has a route beginning with a '/'. Remove this slash as it is unnecessary. If this pattern is targeted in an include(), ensure the include() pattern has a trailing '/'.
?: (urls.W003) Your URL pattern '/books/' [name='catalog:books'] has a name including a ':'. Remove the colon, to avoid ambiguous namespace references.
?: (urls.W005) URL namespace 'same' isn't unique. You may not be able to reverse all URLs in this namespace
?: (urls.W010) Your URL pattern 'year/<int:year/' has an unmatched '<' bracket.

System check identified 6 issues (0 silenced).
Check Meaning

urls.W001

include() with a route ending in $ (the included patterns could never match).

urls.W002

Route begins with /.

urls.W003

Name contains a :.

urls.E004

urlpatterns is not a list of path()/re_path() instances.

urls.W005

Instance namespace is not unique.

urls.E006

MEDIA_URL or STATIC_URL does not end with a slash.

urls.E007, urls.E008

A custom handlerXXX takes the wrong number of arguments, or cannot be imported.

urls.E009

The view is a class: pass View.as_view().

urls.W010

An angle bracket in a route is unmatched.

Run manage.py check in CI so that a bad route fails the build, and manage.py check --deploy for the production checks described elsewhere in this section. To read a whole URLconf, including nested include()`s and namespaces, a tiny management command is enough (the third-party django-extensions package offers `show_urls for the same purpose):

catalog/management/commands/show_routes.py
from django.core.management.base import BaseCommand
from django.urls import URLPattern, URLResolver, get_resolver


def walk(patterns, prefix="", namespace=""):
    for entry in patterns:
        route = prefix + str(entry.pattern)
        if isinstance(entry, URLResolver):
            ns = f"{namespace}{entry.namespace}:" if entry.namespace else namespace
            yield from walk(entry.url_patterns, route, ns)
        elif isinstance(entry, URLPattern):
            yield route, f"{namespace}{entry.name}" if entry.name else "-", entry.lookup_str


class Command(BaseCommand):
    help = "Print every route of the root URLconf as: route, namespaced name, view."

    def handle(self, *args, **options):
        for route, name, view in walk(get_resolver().url_patterns):
            self.stdout.write(f"{route:<42} {name:<22} {view}")
$ python manage.py show_routes | grep -E "^(api|shop/(books|isbn|published/<yyyy:year>/ ))|^(books|isbn|old-books)"
api/books/                                 api:book-list          catalog.views.echo
api/v1/books/                              api:v1:book-list       catalog.views.echo
shop/books/new/                            shop:book-create       catalog.views.BookCreate
shop/books/<slug:slug>/                    shop:book-detail       catalog.views.book_detail
shop/isbn/<isbn13:isbn>/                   shop:by-isbn           catalog.views.echo
shop/published/<yyyy:year>/                shop:by-year           catalog.views.echo
books/new/                                 catalog:book-create    catalog.views.BookCreate
books/<slug:slug>/                         catalog:book-detail    catalog.views.book_detail
isbn/<isbn13:isbn>/                        catalog:by-isbn        catalog.views.echo
old-books/<slug:slug>/                     -                      django.views.generic.base.RedirectView

(The command’s output has one line per pattern, 84 in this project, and the listing above is the part that is relevant.)

Migrating from url() to path() and re_path()

Before Django 2.0 there was one routing function, django.conf.urls.url(), which took a regular expression. Django 2.0 added path() (readable routes with converters) and re_path() (the same regular-expression behaviour as url()), made include importable from django.urls, and Django 3.1 deprecated url(); it was removed in Django 4.0. On Django 6.1 the import fails:

ImportError: cannot import name 'url' from 'django.conf.urls'

(This is also asserted by the test test_old_url_import_is_gone.) The migration has two levels, and you can stop at the first one:

  1. Mechanical: rename url( to re_path( and import it from django.urls. The regular expressions stay, so the same URLs match and the view still receives strings. This is safe and enough to run on a current Django.

  2. Idiomatic: convert the patterns that a converter expresses well to path(), and adapt the views for the new argument types.

Typical conversions:

Old New Notes

url(r"^$", v, name="home")

path("", v, name="home")

No ^, no $, no leading slash.

url(r"^books/(?P<pk>[0-9]+)/$", v)

path("books/<int:pk>/", v)

pk is now an int; /books/007/ still matches.

url(r"^tag/(?P<slug>[-\w]+)/$", v)

path("tag/<slug:slug>/", v)

slug is ASCII only; \w also matches Unicode letters.

url(r"^archive/(?P<year>[0-9]{4})/$", v)

re_path(r"^archive/(?P<year>[0-9]{4})/$", v) or a yyyy converter

<int:year> would accept any number of digits.

url(r"^blog/", include("blog.urls", namespace="blog", app_name="blog"))

path("blog/", include("blog.urls", namespace="blog")) plus app_name = "blog" in blog/urls.py

The app_name argument of include() was removed in 2.0.

url(r"^admin/", include(admin.site.urls))

path("admin/", admin.site.urls)

The 3-tuple form of include() was removed in 2.0.

url(r"^files/(?P<path>.*)$", v)

path("files/<path:path>", v)

The path converter requires at least one character.

The type change is the real risk: a view that did int(year) keeps working with an int, but one that did year.zfill(4) or compared with a string breaks. The tests below show both syntaxes side by side in one URLconf and prove that they do not accept exactly the same URLs.

config/urls_migration.py
from django.urls import include, path, re_path

from catalog import views

urlpatterns = [
    # Before (Django 1.x-3.x):  url(r"^archive/(?P<year>[0-9]{4})/$", views.echo)
    # Step 1, mechanical: same regex, same string arguments (works on Django 2.0+).
    re_path(r"^old/(?P<year>[0-9]{4})/$", views.echo),
    # Step 2, idiomatic: path() with a converter; the view now receives an int.
    path("new/<int:year>/", views.echo),
    # Before: url(r"^blog/", include("blog.urls", namespace="blog", app_name="blog"))
    # After: the application namespace lives in blog/urls.py as `app_name`; include() takes `namespace` only.
    path("blog/", include("catalog.urls", namespace="blog")),
]
catalog/tests.py: the old and the new syntax
@override_settings(ROOT_URLCONF="config.urls_migration")
class UrlMigrationTests(SimpleTestCase):
    def test_re_path_keeps_strings_and_path_gives_ints(self):
        self.assertEqual(resolve("/old/2024/").kwargs, {"year": "2024"})
        self.assertEqual(resolve("/new/2024/").kwargs, {"year": 2024})

    def test_the_two_syntaxes_do_not_accept_exactly_the_same_urls(self):
        with self.assertRaises(Resolver404):
            resolve("/old/10000/")  # [0-9]{4} is exactly four digits
        self.assertEqual(resolve("/new/10000/").kwargs, {"year": 10000})  # <int:...> is any number of digits

    def test_old_url_import_is_gone(self):
        with self.assertRaises(ImportError):
            from django.conf.urls import url  # noqa: F401  (removed in Django 4.0)

Automate the mechanical step. The third-party django-upgrade tool rewrites url() calls when the target version is 2.0 or later. It produces path() where the regular expression is a trivial route that a converter expresses and re_path() everywhere else. Run on this file:

legacy_demo/urls.py before
from django.conf.urls import include, url

from catalog import views

urlpatterns = [
    url(r"^$", views.book_list, name="book-list"),
    url(r"^books/(?P<pk>[0-9]+)/$", views.echo, name="book-detail"),
    url(r"^archive/(?P<year>[0-9]{4})/$", views.echo, name="archive"),
    url(r"^shop/", include("catalog.urls")),
]
$ python -m django_upgrade --target-version 4.0 legacy_demo/urls.py
Rewriting legacy_demo/urls.py
legacy_demo/urls.py after
from django.urls import include, path, re_path

from catalog import views

urlpatterns = [
    path("", views.book_list, name="book-list"),
    path("books/<int:pk>/", views.echo, name="book-detail"),
    re_path(r"^archive/(?P<year>[0-9]{4})/$", views.echo, name="archive"),
    path("shop/", include("catalog.urls")),
]

The tool (version 1.33.0 here; its fixer is named django_urls) exits with status 1 when it changed a file, so it fits a pre-commit hook. It also rewrites existing re_path() calls to path() when the regular expression is eligible. Review its diff, because the int conversion of pk is exactly the type change described above. Other old spellings you will meet in the same files: from django.core.urlresolvers import reverse (module removed in 2.0, use django.urls), string view references such as url(r"^$", "app.views.home") and patterns("", …​) (both removed in 1.10), reverse("app.views.home") with a dotted path (removed in 1.10).

The complete runnable example

All the files above form one small project. To reproduce it, create the app and project as described on Getting Started, add "catalog" to INSTALLED_APPS and "catalog.middleware.HostUrlconfMiddleware" to MIDDLEWARE, add the hosts localhost, 127.0.0.1, testserver and api.localhost to ALLOWED_HOSTS, create the files shown on this page, and run:

$ python manage.py makemigrations catalog
$ python manage.py check
System check identified no issues (0 silenced).
$ python manage.py test catalog
Found 26 test(s).
System check identified no issues (0 silenced).
Creating test database for alias 'default'...
..........................
----------------------------------------------------------------------
Ran 26 tests in 0.026s

OK
Destroying test database for alias 'default'...

The curl session used settings_nodebug.py, a two-line module (from .settings import * followed by DEBUG = False), so that the error handlers run:

$ DJANGO_SETTINGS_MODULE=config.settings_nodebug python manage.py runserver 8822 --noreload
$ curl -si 'localhost:8822/books/dune/'
HTTP/1.1 200 OK
<h1>Dune</h1>
$ curl -si 'localhost:8822/shop/books/dune/'
HTTP/1.1 200 OK
<h1>Dune</h1>
$ curl -si 'localhost:8822/isbn/9780306406157/'
HTTP/1.1 200 OK
{"args": [], "kwargs": {"isbn": ["'9780306406157'", "str"]}, "view_name": "catalog:by-isbn", "route": "isbn/<isbn13:isbn>/", "app_names": ["catalog"], "namespaces": ["catalog"]}
$ curl -si 'localhost:8822/isbn/9780306406158/'
HTTP/1.1 404 Not Found
{"error": "not found", "path": "/isbn/9780306406158/", "detail": ""}
$ curl -si 'localhost:8822/search/?q=dune'
HTTP/1.1 302 Found
Location: /?q=dune&sort=title#results
$ curl -si 'localhost:8822/about'
HTTP/1.1 301 Moved Permanently
Location: /about/
$ curl -si 'localhost:8822/published/2024/3/'
HTTP/1.1 200 OK
{"args": [], "kwargs": {"year": ["2024", "int"], "month": ["3", "int"]}, "view_name": "catalog:by-month", "route": "published/<yyyy:year>/<int:month>/", "app_names": ["catalog"], "namespaces": ["catalog"]}
$ curl -si 'localhost:8822/old-books/dune/'
HTTP/1.1 301 Moved Permanently
Location: /books/dune/
$ curl -si 'localhost:8822/nope/'
HTTP/1.1 404 Not Found
{"error": "not found", "path": "/nope/", "detail": ""}
$ curl -si 'localhost:8822/forbidden/'
HTTP/1.1 403 Forbidden
{"error": "forbidden"}
$ curl -si 'localhost:8822/boom/'
HTTP/1.1 500 Internal Server Error
{"error": "server error"}
$ curl -si 'localhost:8822/api/v1/books/'
HTTP/1.1 200 OK
{"args": [], "kwargs": {}, "view_name": "api:v1:book-list", "route": "api/v1/books/", "app_names": ["api", "v1"], "namespaces": ["api", "v1"]}
$ curl -s -H 'Host: api.localhost:8822' localhost:8822/
{"args": [], "kwargs": {}, "view_name": "api-root", "route": "", "app_names": [], "namespaces": []}

Source: Testing tools and runserver

Sync and async notes

  • Routing is sync and trivial. resolve() is a plain function that scans compiled regular expressions; the ASGI handler calls it directly (self.resolve_request(request)) from the event loop without a thread hop, and converters, reverse(), reverse_lazy() and resolve() are ordinary synchronous, I/O-free functions that are safe to call from async code.

  • The view’s sync or async nature does not affect the pattern. path("ping/", views.ping) works for async def ping(request) and the test test_async_view_is_routed_like_any_other uses AsyncClient against it. A sync view reached through ASGI runs in a thread via sync_to_async; an async view under WSGI is run to completion on a private event loop. The async page of this section explains when each is worthwhile.

  • Keep converters and to_python() free of I/O. A converter that queries the database would run inside the event loop on ASGI, blocking it (and, in sync mode, would run for every non-matching pattern it is tried against). Validate formats in converters and look up rows in the view.

  • reverse() in a coroutine is fine. The only trap is reverse_lazy() proxies stored in module state: they evaluate on first use, in whichever thread uses them first, which is harmless because the URLconf import is thread-safe.

Pitfalls checklist

  • Hardcoded URLs. <a href="/books/{{ book.slug }}/"> and redirect("/books/") break when a prefix changes. Use {% url %}, get_absolute_url() and reverse().

  • reverse() at import time (class attributes, default arguments, module constants) causes circular imports; use reverse_lazy().

  • A missing app_name is the cause of “'catalog' is not a registered namespace” (NoReverseMatch). Set app_name in the included module or use the 2-tuple form.

  • Passing the class, not as_view(), to path(); caught by urls.E009.

  • Specific routes after generic ones, so books/new/ becomes a slug lookup; put literals first.

  • $ on an include() prefix (urls.W001) and a leading / in a route (urls.W002).

  • Forgetting the trailing slash in a form action, so a POST hits APPEND_SLASH; reverse the URL instead.

  • Mixing named and unnamed groups in re_path(); the unnamed ones disappear.

  • Optional named groups pass nothing when absent; give the view a default.

  • Reversing a top-level |; restructure the pattern or add separate patterns for each alternative.

  • Same include() module mounted twice with the same instance namespace (urls.W005); or relied on "last deployed instance" without a default instance.

  • handler404 seems ignored, because DEBUG = True shows the technical 404 page; or because the handler was set in a URLconf other than the root one.

  • Echoing str(exception) in a 404 handler leaks the list of patterns tried.

  • Heavy work in handler500. Return something static.

  • Registering a converter name twice (for example by reloading a URLconf module in a test) raises ValueError since Django 6.0.

  • int leading zeros and unbounded size give duplicate URLs; use a stricter custom converter.

  • Routing on the query string is impossible; the URLconf never sees it. Branch in the view.

  • Different behaviour per HTTP method belongs in the view (or require_http_methods), not in extra patterns: the URLconf ignores the method.

Where the books are outdated

The four reference books were written for Django 1.11 to 5; their URL chapters are right about the ideas (namespaces, named patterns, reverse() instead of hardcoded URLs) and wrong about several spellings:

  • url(). Older code and the Django 1.11 and 2 era books use from django.conf.urls import url with ^…​$ regular expressions. url() was deprecated in 3.1 and removed in 4.0. Use path() and re_path() (added in 2.0), see Migrating from url() to path() and re_path().

  • include() arguments. include("app.urls", namespace="x", app_name="x") and the 3-tuple include(admin.site.urls) were removed in 2.0; the application namespace is app_name in the module (or the 2-tuple), and admin.site.urls goes straight into path().

  • django.core.urlresolvers. Removed in 2.0; import reverse, reverse_lazy, resolve and NoReverseMatch from django.urls.

  • Pre-1.10 idioms such as patterns("", …​), string view names and reversing by dotted path, still visible in old snippets, were removed in 1.10.

  • Everything is a string in the URL regexes of those books. With path() and converters the view receives an int, UUID or custom type, so code such as int(pk) is redundant (harmless) and pk.isdigit() raises AttributeError.

  • Logout links. Books that link to /accounts/logout/ with a GET produce a 405 on Django 5.0 and later, because LogoutView is POST-only.

  • Error handlers. A custom def handler404(request) (or 400 or 403) without the exception argument fails: the argument has been required since Django 2.0, and urls.E007 reports the mismatch at startup. Only handler500 takes just the request.

  • Query strings for redirects are built by hand with urlencode in older code; reverse(query=…​, fragment=…​) does it since 5.2.

What changed recently

Version Change relevant to URLs and routing

6.1

RedirectView.preserve_request keeps the HTTP method and body across the redirect. The URL dispatcher itself, path(), include(), reverse() and the converters have no changes in the 6.1 release notes.

6.0

Overriding an existing converter with register_converter() is no longer allowed (raises ValueError; deprecated in 5.1).

5.2 LTS

reverse() and reverse_lazy() accept the keyword-only arguments query and fragment.

5.1

The undocumented django.urls.converters.get_converter() was removed; overriding a registered converter was deprecated; the {% querystring %} template tag arrived (for query strings in templates).

5.0

no_append_slash is listed among the decorators that support async views; LogoutView accepts only POST.

4.0

django.conf.urls.url() removed.

3.2

The @no_append_slash decorator; the admin’s final catch-all view (can disable with AdminSite.final_catch_all_view = False).

3.1

Converters may raise ValueError from to_url() to mean "no match when reversing"; url() deprecated; async views.

2.0

path(), re_path() and from django.urls import include; the app_name argument and 3-tuple form of include() removed; django.core.urlresolvers removed; error handlers must accept exception; error-handler defaults became callables.