Django URL routing
last modified November 13, 2026 Python 3.15
In this article we show how Django maps URLs to views. We work with
path(), path converters, re_path(),
include(), URL namespaces, reverse(), the
{% url %} template tag, and a custom path converter. The examples
use Django 6.1 and Python 3.15.
URL routing
URL routing is the process of choosing the view that handles an
incoming request. Django does it with a URLconf, a Python module
that contains a list named urlpatterns. Django walks through the
list from top to bottom, stops at the first pattern that matches the requested
path, and calls its view with the HttpRequest and the values
captured from the URL. If no pattern matches, Django returns a 404 response.
Django matches only the path part of the URL; the scheme, the host name, the
query string, and the HTTP method are not considered. The query string is
available in request.GET (see
Django HttpRequest).
The example project
The project is created with uv; the steps are explained in
Django introduction. The commands below create a project
directory, install Django, generate the project with the configuration package
config, and start the apps catalog and blog.
$ mkdir routing $ cd routing $ uv init --bare $ uv add django $ uv run django-admin startproject config . $ uv run python manage.py startapp catalog $ uv run python manage.py startapp blog
The trailing dot in startproject config . creates manage.py and the
config package in the current directory instead of one level deeper. A new app is
ignored by Django until it is listed in INSTALLED_APPS, so we add
the apps to the list in config/settings.py; the other
entries are generated by startproject.
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'catalog',
'blog',
]
Every listing in this article is a file of the application, shown with a solid blue border and the label Project file.
The root URLconf
The ROOT_URLCONF setting names the module where Django starts
the lookup; startproject sets it to config.urls.
We replace the generated file with a small one that delegates to the URLconfs
of the two apps.
from django.urls import include, path
urlpatterns = [
path('', include('catalog.urls')),
path('blog/', include('blog.urls')),
]
The include() function takes a module name (or a list of
patterns) and mounts it under a prefix. When Django finds a match for the
prefix, it removes the matched part of the path and continues with the remaining
part in the included URLconf. The catalog is mounted at the empty prefix, so
its URLs start at the site root. The blog lives under /blog/.
Using include() keeps every app self-contained: the app owns its
routes and the project only decides where to mount them.
Views
The views in the examples return plain text, so that the output is easy to
read. The small text() helper builds a text/plain
response. Every view reports the Python type of the captured value, which
shows what the converters pass in.
from django.http import HttpResponse
from django.shortcuts import render
from django.urls import resolve, reverse
def text(content):
return HttpResponse(content + '\n', content_type='text/plain')
def index(request):
return text('Home page')
def product_list(request):
return text('All products')
def product_detail(request, pk):
return text(f'Product {pk} ({type(pk).__name__})')
def category(request, slug):
return text(f'Category {slug}')
def files(request, filepath):
return text(f'File {filepath}')
def item(request, uid):
return text(f'Item {uid} ({type(uid).__name__})')
def archive(request, year, month=None):
if month is None:
return text(f'Archive {year} ({type(year).__name__})')
return text(f'Archive {year}-{month:02d}')
def legacy(request, year):
return text(f'Legacy archive {year} ({type(year).__name__})')
def page(request, title):
return text(f'Page: {title}')
def links(request):
return render(request, 'catalog/links.html', {'product_id': 7})
def reverse_demo(request):
lines = [
reverse('catalog:index'),
reverse('catalog:product-detail', args=[42]),
reverse('catalog:product-detail', kwargs={'pk': 42}),
reverse('catalog:archive-month', kwargs={'year': 2026, 'month': 11}),
reverse('blog:post', args=[3]),
]
match = resolve('/products/42/')
lines.append(f'{match.view_name} {match.args} {match.kwargs}')
return text('\n'.join(lines))
Besides the views, the file contains links(), which renders a
template, and reverse_demo(); we describe both later. Because the
views only echo converted values (integers, slugs, UUIDs), no user-provided text
is inserted into an HTML page.
The path() function
The path() function creates a URL pattern. Its first argument is
the route, the second is the view, and the optional name keyword
argument names the pattern so that it can be reversed. The route is a plain
string compared with the path; angle brackets capture a part of the path using
the <converter:name> syntax. We write the URLconf of the
catalog app.
from django.urls import path, re_path, register_converter
from . import converters, views
register_converter(converters.FourDigitYearConverter, 'yyyy')
app_name = 'catalog'
urlpatterns = [
path('', views.index, name='index'),
path('products/', views.product_list, name='product-list'),
path('products/<int:pk>/', views.product_detail, name='product-detail'),
path('categories/<slug:slug>/', views.category, name='category'),
path('files/<path:filepath>', views.files, name='files'),
path('items/<uuid:uid>/', views.item, name='item'),
path('archive/<yyyy:year>/', views.archive, name='archive'),
path('archive/<yyyy:year>/<int:month>/', views.archive, name='archive-month'),
re_path(r'^legacy/(?P<year>[0-9]{4})/$', views.legacy, name='legacy'),
path('about/', views.page, {'title': 'About us'}, name='about'),
path('links/', views.links, name='links'),
path('reverse/', views.reverse_demo, name='reverse-demo'),
]
The routes in the file demonstrate the main features. We go through them one
by one. Note that the routes have no leading slash: Django strips it before
matching. The trailing slash in the routes is a convention that matches the
default APPEND_SLASH behavior described below.
Static routes
The simplest routes contain no captured values. The empty route matches the
root of the mount point and products/ matches exactly that path.
path('', views.index, name='index'),
path('products/', views.product_list, name='product-list'),
Matching is exact: /products/ matches, but
/products/extra/ does not, because the route is compared with the
whole remaining path.
$ curl -s http://127.0.0.1:8000/ Home page $ curl -s http://127.0.0.1:8000/products/ All products
Path converters
A path converter captures a part of the URL, converts it to a Python value, and passes it to the view as a keyword argument with the name given after the colon. Django has five built-in converters.
str- matches any non-empty string, excluding the path separator/; this is the default when no converter is givenint- matches zero or any positive integer and returns anintslug- matches ASCII letters, digits, hyphens, and underscores, such asgarden-toolsuuid- matches a formatted UUID and returns auuid.UUIDinstancepath- matches any non-empty string, including the path separator; it is useful for file paths
Negative numbers do not match int, because the converter's
regular expression is [0-9]+. Capture a sign yourself with a custom
converter if you need one.
Using int, slug, path, and uuid
The product detail route captures an integer, the category route a slug, the files route a path with slashes, and the item route a UUID.
path('products/<int:pk>/', views.product_detail, name='product-detail'),
path('categories/<slug:slug>/', views.category, name='category'),
path('files/<path:filepath>', views.files, name='files'),
path('items/<uuid:uid>/', views.item, name='item'),
The converted values arrive in the views as an int and as a
uuid.UUID; the slug and the path stay strings. The
files/ route has no trailing slash, because the captured path
may itself end with a file name.
$ curl -s http://127.0.0.1:8000/products/42/ Product 42 (int) $ curl -s http://127.0.0.1:8000/categories/garden-tools/ Category garden-tools $ curl -s http://127.0.0.1:8000/files/docs/2026/report.pdf File docs/2026/report.pdf $ curl -s http://127.0.0.1:8000/items/123e4567-e89b-12d3-a456-426614174000/ Item 123e4567-e89b-12d3-a456-426614174000 (UUID)
Values that do not match
When the captured text does not satisfy the converter, the pattern does not
match, and Django continues with the next one. Here no other pattern matches,
so the response is 404. The -w option of curl prints the
status code, and -o /dev/null discards the body (in debug mode it
is a long error page listing the patterns that were tried).
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/products/abc/
404
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/products/-5/
404
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/items/not-a-uuid/
404
Custom path converters
A converter is a class with a regex attribute and two methods.
The to_python() method converts the matched string to the value that
the view receives; the to_url() method converts a value back to a
string when a URL is built with reverse(). If
to_python() raises ValueError, the pattern does not
match. We write a converter for four-digit years.
class FourDigitYearConverter:
regex = '[0-9]{4}'
def to_python(self, value):
return int(value)
def to_url(self, value):
return '%04d' % value
The regular expression accepts exactly four digits, to_python()
turns them into an int, and to_url() pads the number
with zeros, so a year such as 987 becomes 0987.
Registering a converter
The register_converter() function makes the converter available
under a name. We call it at the top of catalog/urls.py, before the
patterns that use it, and name the converter yyyy.
register_converter(converters.FourDigitYearConverter, 'yyyy')
path('archive/<yyyy:year>/', views.archive, name='archive'),
path('archive/<yyyy:year>/<int:month>/', views.archive, name='archive-month'),
Both patterns use the same archive() view. The view has a
month=None default, which is used when the first pattern matches.
Two patterns for one view is the usual way to express optional URL parts, since
a path converter cannot be optional. A three-digit year does not match.
$ curl -s http://127.0.0.1:8000/archive/2026/
Archive 2026 (int)
$ curl -s http://127.0.0.1:8000/archive/2026/11/
Archive 2026-11
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8000/archive/987/
404
Regular expression routes
When the built-in converters are not flexible enough, we use
re_path(), which takes a regular expression instead of a route
string. Named groups, written as (?P<name>pattern), are
passed to the view as keyword arguments. The matching uses the search rules
of re.search() on the whole remaining path, so we anchor the
expression with ^ and $.
re_path(r'^legacy/(?P<year>[0-9]{4})/$', views.legacy, name='legacy'),
Captured groups are always passed as strings; no conversion takes place.
The output shows the str type of the year value, unlike
the int that the converter gave in the previous example.
$ curl -s http://127.0.0.1:8000/legacy/2026/ Legacy archive 2026 (str)
Extra options
The optional third argument of path() is a dictionary of extra
keyword arguments for the view. It lets us reuse one view for several routes
with different settings.
path('about/', views.page, {'title': 'About us'}, name='about'),
The view declares def page(request, title) and receives the
title value from the dictionary, although it does not appear in
the URL.
$ curl -s http://127.0.0.1:8000/about/ Page: About us
Namespaces
Both apps use route names such as index. A name must be unique
only inside its namespace: an app declares one with the app_name
variable in its urls.py, as we did in the catalog and the blog
apps. Names are then used with the namespace and a colon, as in
catalog:index and blog:index, so the two apps can use
the same names without a clash. The blog app is small: a URLconf and two views.
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
path('', views.index, name='index'),
path('<int:pk>/', views.post, name='post'),
]
The post route captures an integer, which the view receives as the argument
pk. The views of the blog return plain text.
from django.http import HttpResponse
def index(request):
return HttpResponse('Blog index\n', content_type='text/plain')
def post(request, pk):
return HttpResponse(f'Blog post {pk}\n', content_type='text/plain')
Because the project mounts the blog under blog/, the
post route is available at /blog/3/. The two routes
are served as follows.
$ curl -s http://127.0.0.1:8000/blog/ Blog index $ curl -s http://127.0.0.1:8000/blog/3/ Blog post 3
The reverse() function
Hard-coding URLs in views and templates is brittle, because a route change
breaks every copy. The reverse() function builds the URL from the
name of the pattern; positional or keyword arguments fill the captured
parts. The resolve() function does the opposite: it finds the view
for a path and returns a ResolverMatch object. The
reverse_demo() view shows both.
reverse('catalog:index')
reverse('catalog:product-detail', args=[42])
reverse('catalog:product-detail', kwargs={'pk': 42})
reverse('catalog:archive-month', kwargs={'year': 2026, 'month': 11})
reverse('blog:post', args=[3])
resolve('/products/42/')
The namespaced name is written as namespace:name. The year
is formatted by the to_url() method of our converter. A mix of
positional and keyword arguments is not allowed. The resolve() call
returns the full view name, the positional arguments, and the keyword arguments
as converted by the URL patterns. If the name or the arguments do not match any
pattern, reverse() raises NoReverseMatch.
$ curl -s http://127.0.0.1:8000/reverse/
/
/products/42/
/products/42/
/archive/2026/11/
/blog/3/
catalog:product-detail () {'pk': 42}
The url template tag
Templates use the {% url %} tag, which is the template
counterpart of reverse(). It takes the pattern name followed by
positional or keyword arguments. The template below builds four links; the
product_id value comes from the view context.
<ul>
<li><a href="{% url 'catalog:product-list' %}">Products</a></li>
<li><a href="{% url 'catalog:product-detail' pk=product_id %}">Product {{ product_id }}</a></li>
<li><a href="{% url 'catalog:category' 'garden-tools' %}">Garden tools</a></li>
<li><a href="{% url 'blog:post' 3 %}">Blog post 3</a></li>
</ul>
The first link needs no arguments. The second passes the argument as the
keyword pk, the third and the fourth as positional values. The
generated links follow the routes, so if we change products/ to
shop/ in the URLconf, every link is updated without touching the
template.
$ curl -s http://127.0.0.1:8000/links/ <ul> <li><a href="/products/">Products</a></li> <li><a href="/products/7/">Product 7</a></li> <li><a href="/categories/garden-tools/">Garden tools</a></li> <li><a href="/blog/3/">Blog post 3</a></li> </ul>
The trailing slash
The APPEND_SLASH setting is True by default. When
a request for a path without a trailing slash matches no pattern, but the same
path with a slash does, CommonMiddleware answers with a permanent
redirect (status 301) to the version with the slash. This is why the routes end
with a slash. A POST, PUT, or PATCH
request to the version without a slash would lose its body in the redirect,
so in debug mode Django raises an error for these methods instead. The -w option
prints the status code and the redirect target.
$ curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" http://127.0.0.1:8000/products
301 http://127.0.0.1:8000/products/
The order of patterns
The first matching pattern wins, so the order matters. If a general pattern
such as path('<slug:page>/', ...) stands before a specific one
such as path('about/', ...), the specific route is never reached.
Put specific routes first. Django checks the URLconfs with the
check command; it reports mistakes such as a route that starts with
a slash or an include() route that ends with a dollar sign.
$ uv run python manage.py check System check identified no issues (0 silenced).
Source
Django documentation: URL dispatcher
Django documentation: django.urls functions
In this article we have mapped URLs to views with path() and
re_path(), captured values with built-in and custom converters,
split the routes between apps with include() and namespaces, and
built URLs with reverse() and the {% url %} tag. The
views that receive the URLs are the topic of the next article; see also
Django introduction and
Django HttpRequest.
Author
List all Django tutorials.