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:
-
Django picks the root URLconf module. Normally this is the
ROOT_URLCONFsetting, but if theHttpRequesthas aurlconfattribute (set by a middleware that ran earlier) its value is used instead. -
Django imports that module and reads its
urlpatternsvariable, a sequence ofpath()and/orre_path()instances (andinclude()results). -
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. -
It imports and calls the view with the
HttpRequestas the first argument, plus the captured values as keyword arguments (or positional arguments, if the pattern used unnamed regular-expression groups), overridden by the optionalkwargsdictionary of the pattern. -
If no pattern matches, or any exception is raised on the way, Django calls an error-handling view.
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/andPOST /books/reach the same view); the view or a decorator decides what to do with the method. -
request.resolver_matchexists only after resolution. It is available in views, inprocess_view()and later hooks, and in template contexts, but not in the "before the view" part of a middleware’scall, 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()):
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})
Source: Model.get_absolute_url()
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:
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:
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")),
]
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")),
]
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:
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})
Source: URL dispatcher and Writing views
path(): routes, kwargs and name
path(route, view, kwargs=None, name=None) returns one element of urlpatterns.
-
routeis a string (or agettext_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 isstrand matches any non-empty text except/. -
viewis a function, the result ofView.as_view()for class-based views, or aninclude(). Passing the class itself (a frequent slip:views.BookCreateinstead ofviews.BookCreate.as_view()) is caught by the system checkurls.E009. -
kwargsis a dictionary of extra keyword arguments added to every call (Extra options and default arguments). -
namemakes 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 checkurls.W002). -
Order matters: the first match wins. In
catalog/urls.pybooks/new/comes beforebooks/<slug:slug>/; swap them and the create page becomes a book with the slugnew, whichget_object_or_404turns 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 assitemap.xmlorrobots.txt) and keep to it. -
The method, the query string and the host are not matched.
https://example.com/books/?page=3is looked up asbooks/. -
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():
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}))
Source: django.urls.path() and URL dispatcher: example
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 |
|---|---|---|---|
|
|
|
Any non-empty text excluding |
|
|
|
Zero or any positive integer. No sign, no limit on digits, leading zeros accepted ( |
|
|
|
ASCII letters, digits, hyphen and underscore. Non-ASCII slugs from |
|
|
|
Dashes required and letters lowercase, so one UUID has exactly one URL. |
|
|
|
Any non-empty text including |
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"]}
Source: Django: Path converters
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:
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, …), raisesValueError("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
regexis 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:
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:
-
intaccepts leading zeros and has no upper bound./page/007/resolves to the same view as/page/7/, andreverse()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]*). -
slugis ASCII only. If your slugs contain accented characters, usestrwith 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\dneed no doubled backslash. -
If the route ends in
$, the whole ofpath_infomust match (re.fullmatch()is used); otherwise the pattern matches a prefix, which is howinclude()prefixes work withre_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 betweenpath()andre_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:
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
slugis passed; the unnamed(\d+)group is silently dropped. -
The "bad"
blogpattern 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"
commentspattern with(?:page-…)passes onlypage_number='2', and when the optional part is missing the keyword argument is omitted altogether, not passed asNone. A view behind an optional named group therefore needs a default value (def comments(request, page_number=1)). Theechoview above printed an emptykwargsfor/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()raisesNoReverseMatch. Django documents this restriction: patterns that you reverse must not contain choices with|outside a captured group. -
/n/007/matchedpath("n/<int:n>/")withn=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
kwargsdictionary (third positional argument ofpath()):path("about/", views.echo, {"template": "about"}, name="about")callsecho(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/andpage/<int:num>/both point atviews.page, whose signature isdef 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})passesblog_id=3to 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.
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’sapp_name. -
include(pattern_list): any iterable ofpath()/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"))passesusernameto every view offoo/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 checkurls.W001warns about it (System checks for the URLconf). -
A trailing slash on the prefix is conventional.
include("catalog.urls")underpath("shop", …)would give/shopbooks/. -
include()order is match order.path("", include("catalog.urls"))inconfig/urls.pyis 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.pyshort 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.
Source: django.urls.include() and Including other URLconfs
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, notbooks-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’surlpatterns. 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
LoginViewis found byreverse("login"); if you name your own login routeloginand include it afterdjango.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 raisesValueErrorinto_url().
Source: Naming URL patterns
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_namein the included module or from the 2-tuple form ofinclude(). Django’s admin has the application namespaceadmin. -
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"toinclude()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:
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")andreverse("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 namespacecatalog):/. With only theshopandoutletincludes (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/. Thecurrent_apphint is how a page rendered inside theoutletinstance 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 setrequest.current_app(The {% url %} template tag). In a class-based view, passcurrent_app=self.request.resolver_match.namespacetoreverse()yourself.
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 samenamespacemake one of them unreachable by name; system checkurls.W005warns ("URL namespace … isn’t unique"). -
A namespace without an
app_nameis anImproperlyConfigurederror 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
AdminSiteinstances 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.
-
viewnameis a pattern name ("catalog:book-detail") or, discouraged, a callable view object from the URLconf. A view built withas_view()can be reversed by object only if the same object is used in the URLconf, and namespaced views cannot be reversed by object. -
argsorkwargs, never both: passing both raisesValueError. -
current_appis the hint described in Application and instance namespaces. -
urlconfnames a different URLconf module to reverse against (default: the root URLconf of the current thread, which isrequest.urlconfif a middleware set one). -
query(Django 5.2 and later, keyword-only) is added to the returned URL after a?. It accepts aQueryDict(for examplerequest.GET) or anythingurllib.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,
NoReverseMatchis 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.
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)
Source: Django: reverse()
/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/. -
querywith a list of pairs repeats keys (tag=sf&tag=classic); aNonevalue becomes the textNone(it is not skipped), so filter out missing values yourself. AQueryDictkeeps repeated keys, which is what you want when you rebuild a filter URL fromrequest.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
NoReverseMatchfor the invalid ISBN came from the converter’sto_url()raisingValueError. -
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_urlattribute of a generic class-based view (a class body runs at import); -
the
login_urlargument of a decorator such aspermission_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 |
|---|---|
|
The view callable (for a class-based view, the function returned by |
|
The positional and keyword arguments the view will receive. |
|
The values captured from the URL, and the values added by the |
|
The |
|
The route of the matching pattern, for example |
|
The application namespace (and its components: |
|
The instance namespace and its components. |
|
The name including the namespace, e.g. |
|
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().
<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 raiseNoReverseMatchwhen 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 varraisesNoReverseMatchand the page becomes a 500 (a debug page withDEBUG = True). That is the intended fail-fast behaviour for a link you expect to exist. -
The tag has no
queryorfragmentargument. 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 namedbook-detail, which is a syntax orNoReverseMatcherror 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
shopinstance links to/shop/, as the testtest_url_tag_follows_the_current_instanceverifies. It needsdjango.template.context_processors.requestor aRequestContext(render()provides one). To override the choice, setrequest.current_appin 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:
<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 |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
Any other exception raised by a view |
|
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:
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)
@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,handler404is not called. Django shows its technical 404 page (with the list of patterns tried). That is why the test above overridesDEBUG=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 aResolver404whose 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 thecurloutput contained every route, including the admin prefix). The handler above only uses the message when it is a plain string (for exampleHttp404("no such book")). -
Keep
handler500trivial. 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 defineshandler403 = …, as the official documentation shows;Client.raise_request_exception = Falselets 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.:
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)
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.
Source: HttpRequest.urlconf and How Django processes a request
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 unslashedPOST,PUT,PATCHorDELETEthat would need the redirect raises aRuntimeErrorexplaining the problem, so that you notice in development. In production it silently becomes a301and the data is lost; point forms at the slashed URL (generate it with{% url %}). -
Opt a view out with
@no_append_slashfromdjango.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.
/nopestays a 404, not a redirect to/nope/. -
Set
APPEND_SLASH = Falsefor an API whose clients must get a clean 404 for the wrong spelling. -
Case counts.
/Books/does not matchbooks/. Django does not normalise case; add a redirect or lower-case at the proxy if you need tolerance.
Source: CommonMiddleware and APPEND_SLASH
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.
Source: RedirectView and Django 6.1 release notes
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)(namespaceadmin;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 afteradmin/that also starts withadmin/becomes unreachable. Reorder, or setAdminSite.final_catch_all_view = False. -
Authentication:
path("accounts/", include("django.contrib.auth.urls"))addslogin,logout,password_changeand the password reset URLs under unnamespaced names (login,logout…), which is whyLOGIN_URL = "login"works.LogoutViewaccepts 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)fromdjango.conf.urls.staticreturns a pattern that serves files whenDEBUGis 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_URLmust end with a slash (system checkurls.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 separateAdminSiteinstances, which is the instance-namespace feature at work.
Source: django.conf.urls.static.static(), AdminSite, and Authentication views
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:
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,
)
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.
Source: get_script_prefix() and FORCE_SCRIPT_NAME
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:
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 |
|---|---|
|
|
|
Route begins with |
|
Name contains a |
|
|
|
Instance namespace is not unique. |
|
|
|
A custom |
|
The view is a class: pass |
|
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):
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:
-
Mechanical: rename
url(tore_path(and import it fromdjango.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. -
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 |
|---|---|---|
|
|
No |
|
|
|
|
|
|
|
|
|
|
|
The |
|
|
The 3-tuple form of |
|
|
The |
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.
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")),
]
@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:
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
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()andresolve()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 forasync def ping(request)and the testtest_async_view_is_routed_like_any_otherusesAsyncClientagainst it. A sync view reached through ASGI runs in a thread viasync_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 isreverse_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.
Source: Asynchronous support
Pitfalls checklist
-
Hardcoded URLs.
<a href="/books/{{ book.slug }}/">andredirect("/books/")break when a prefix changes. Use{% url %},get_absolute_url()andreverse(). -
reverse()at import time (class attributes, default arguments, module constants) causes circular imports; usereverse_lazy(). -
A missing
app_nameis the cause of “'catalog' is not a registered namespace” (NoReverseMatch). Setapp_namein the included module or use the 2-tuple form. -
Passing the class, not
as_view(), topath(); caught byurls.E009. -
Specific routes after generic ones, so
books/new/becomes a slug lookup; put literals first. -
$on aninclude()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. -
handler404seems ignored, becauseDEBUG = Trueshows 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
ValueErrorsince Django 6.0. -
intleading 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 usefrom django.conf.urls import urlwith^…$regular expressions.url()was deprecated in 3.1 and removed in 4.0. Usepath()andre_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-tupleinclude(admin.site.urls)were removed in 2.0; the application namespace isapp_namein the module (or the 2-tuple), andadmin.site.urlsgoes straight intopath(). -
django.core.urlresolvers. Removed in 2.0; importreverse,reverse_lazy,resolveandNoReverseMatchfromdjango.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 anint,UUIDor custom type, so code such asint(pk)is redundant (harmless) andpk.isdigit()raisesAttributeError. -
Logout links. Books that link to
/accounts/logout/with a GET produce a 405 on Django 5.0 and later, becauseLogoutViewis POST-only. -
Error handlers. A custom
def handler404(request)(or 400 or 403) without theexceptionargument fails: the argument has been required since Django 2.0, andurls.E007reports the mismatch at startup. Onlyhandler500takes just the request. -
Query strings for redirects are built by hand with
urlencodein older code;reverse(query=…, fragment=…)does it since 5.2.
Source: Django Deprecation Timeline and Django 2.0 release notes
What changed recently
| Version | Change relevant to URLs and routing |
|---|---|
6.1 |
|
6.0 |
Overriding an existing converter with |
5.2 LTS |
|
5.1 |
The undocumented |
5.0 |
|
4.0 |
|
3.2 |
The |
3.1 |
Converters may raise |
2.0 |
|