ZetCode

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.

config/settings.py
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.

config/urls.py
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.

catalog/views.py
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.

catalog/urls.py
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.

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.

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

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

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

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.

blog/urls.py
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.

blog/views.py
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.

catalog/templates/catalog/links.html
<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

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Django tutorials.