59.5 URL Patterns and Routing
Right, let’s talk about routing, or as I like to call it, “How Django stops your website from being a single, confused page staring blankly into the void.” It’s the bouncer at the club of your web app, checking the URL (the invite) and deciding which view function gets to handle the request (gets past the velvet rope). Do this wrong, and you’ll have views crashing parties they weren’t invited to, and users getting 404s while staring at a perfectly good function that’s just sitting there, unemployed.
At its heart, URL routing in Django is a simple, elegant, and powerful system. You define a list of patterns, and Django, from top to bottom, tries to match the incoming request’s path against them. The first one that matches wins, and the associated view is called. It’s a declarative list of “if you see this, do that.” The magic happens in your project’s urls.py file and any app-specific urls.py files you include.
The Humble path() and Its Two Jobs
The workhorse here is django.urls.path(). It’s deceptively simple. It takes two required arguments and one fantastically useful optional one:
# myapp/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('articles/2023/', views.article_2023_list),
path('articles/<int:year>/', views.year_archive),
path('articles/<int:year>/<slug:slug>/', views.article_detail),
]
Its first job is to match a literal string pattern, like 'articles/2023/'. Its second, more interesting job, is to capture parts of the URL. Those angle-bracketed sections like <int:year> are converters. They tell Django: “Hey, grab this part of the URL, try to convert it to an integer (int), and pass it to the view function as a keyword argument named year.” This is why your view signature might look like def year_archive(request, year):—Django is unpacking the URL right into your function’s arguments. It’s wildly convenient and saves you from a ton of tedious string parsing.
Why re_path() is Your Regex Escape Hatch
Sometimes, path()’s built-in converters (str, int, slug, uuid, path) aren’t enough. You need the raw, unfiltered power of regular expressions. For this, we break out django.urls.re_path() (which used to be just url() back in the day, a name I still mutter under my breath sometimes).
Let’s say you want to match a URL pattern for pages that must start with a word character and end with exactly three digits. path() taps out. re_path() steps in.
# myapp/urls.py
from django.urls import re_path
from . import views
urlpatterns = [
re_path(r'^special/(?P<prefix>\w+)_(?P<id>\d{3})/$', views.special_view),
]
Notice the named capture groups (?P<prefix>\w+) and (?P<id>\d{3}). These serve the same purpose as the converters in path(): Django will pass them as keyword arguments prefix and id to your special_view function. Pro tip: Always use named capture groups. Your future self, trying to decipher what \d{3} was supposed to represent, will thank you. Using unnamed groups is a one-way ticket to confusionville.
The Art of include() and Namespacing
You could write all your routes in the project’s main urls.py. Please don’t. It becomes an unmaintainable mess faster than you can say “technical debt.” Instead, you delegate. Each app should own its own URLs. You do this with include().
# project/urls.py
from django.urls import include, path
urlpatterns = [
path('blog/', include('blog.urls')),
path('store/', include('ecommerce.urls')),
path('', include('core.urls')), # includes routes for the root path ''
]
When Django encounters include(), it chops off the already-matched part of the URL (everything up to and including 'blog/') and sends the remaining bit (let’s say '2023/django-routing') down to the blog.urls file to be matched. This is how you keep things modular and sane.
Now, for the pièce de résistance: namespacing. When you name your paths (e.g., path('articles/<int:id>/', views.article_detail, name='article_detail')), you can reverse them in templates with {% url 'article_detail' id=article.id %}. But what if two apps both have a detail view? They’ll clash. This is where the app_name comes in.
# blog/urls.py
from django.urls import path
from . import views
app_name = 'blog' # <- This is the magic sauce
urlpatterns = [
path('articles/<int:id>/', views.article_detail, name='detail'),
]
Now, in your templates, you can be specific: {% url 'blog:detail' id=article.id %}. It’s a small thing that prevents a huge class of bugs. Always set app_name in your app’s urls.py.
The Order of Operations Matters. A Lot.
This is the most common rookie mistake, and it’ll bite you every time. Django’s URL resolver is not a smart matching algorithm; it’s a dumb, linear, top-to-bottom list checker. The first match wins. Therefore, order your patterns from most specific to most general.
# WRONG. This will never work as intended.
urlpatterns = [
path('articles/<int:year>/', views.year_archive), # This matches first, every time!
path('articles/2023/', views.article_2023_list), # This is now unreachable.
]
# RIGHT. Specific first, generic last.
urlpatterns = [
path('articles/2023/', views.article_2023_list), # Specific case checked first.
path('articles/<int:year>/', views.year_archive), # Generic catch-all later.
]
If you find yourself wondering why a view is never being called, 99% of the time you’ve put a broad pattern above a specific one. I’ve done it. You’ll do it. We all do it. Check your order first.