From
path()to custom converters, namespaces,reverse(), and routing patterns that scale — an engineer's complete guide to Django's URL dispatcher.
orginally posted : https://alansomathewdev.blogspot.com/2026/04/django-url-routing-deep-dive-complete.html
Table of Contents
- Introduction
- Why This Matters in Production Systems
- Core Concepts
- Architecture Design
- Step-by-Step Implementation
- Code Examples
- Performance Optimization
- Security Best Practices
- Common Developer Mistakes
- Real Production Use Cases
- Conclusion
Introduction
Every request that reaches a Django application has to find its view. That job — matching an incoming URL string to a Python callable — belongs to Django's URL dispatcher. It sounds simple. In practice, the URL routing system is one of the most consequential architectural decisions you make in a Django project, and the one most developers stop exploring after the basics.
Most tutorials cover path(), include(), and maybe URL names. They stop long before custom converters, namespace resolution strategies, reverse() under load, internationalized URL patterns, the compiled regex cache, or security patterns that prevent path traversal and enumeration attacks.
This guide goes the full distance. We'll trace an HTTP request through the Django URL resolution pipeline from ROOT_URLCONF to ResolverMatch, build production-grade URL architectures for multi-app projects and REST APIs, create custom path converters, master namespace resolution, handle all four error views, and cover every performance and security consideration you'll encounter in a real codebase.
Whether you're cleaning up a tangle of 200 URL patterns in a legacy app or designing a clean URL hierarchy for a new platform, you'll leave this article with both the vocabulary and the patterns to do it right.
Why This Matters in Production Systems
URL routing sits at the boundary between the external world and your application logic. Getting it right matters for several reasons that aren't obvious until you're operating at scale.
Maintainability. A URL architecture without namespaces, without reverse(), with hardcoded strings in templates and redirects, is a codebase where changing a URL requires searching for every hardcoded string across the entire project. That's the most fragile kind of change.
Security. URL patterns are the first gate between an attacker and your views. Overly permissive regex patterns, missing trailing slash enforcement, and exposed admin URLs are all routing-level vulnerabilities. The URL dispatcher's behaviour around 404s and how you expose your URL structure to the outside world has real security implications.
Performance. Django compiles and caches URL patterns on first access — subsequent requests use the cached configuration via the URL resolver. Pattern ordering, regex complexity, and how you structure nested include() chains all affect resolution time on every single request.
API design. The URLs your API exposes are a public contract. They appear in client code, in documentation, in third-party integrations. A poorly designed URL schema is a versioning nightmare. A well-designed one can evolve safely behind API version prefixes.
Debugging. NoReverseMatch is one of the most common Django exceptions. Understanding resolve(), reverse(), and namespace resolution is the difference between debugging in minutes and debugging for hours.
Core Concepts
The URL Resolution Pipeline
When Django receives an HTTP request, the URL resolution process follows a strict sequence:
- The
WSGIHandler(orASGIHandler) reads the path from the request - It consults
settings.ROOT_URLCONFto find the root URLconf module - The
URLResolverloads that module (once, then caches it) - Django walks
urlpatternsin order, trying each pattern against the path - The first match wins — Django extracts captured groups and calls the view
path(): Readable, Type-Safe Routing
path() was introduced in Django 2.0 as a clean alternative to re_path(). It uses a simplified converter syntax instead of raw regular expressions:
from django.urls import path
# Syntax: path(route, view, kwargs=None, name=None)
path("articles/<int:pk>/", article_detail, name="article-detail")
# ^^^^^^^^
# converter:parameter_name
Built-in path converters:
| Converter | Matches | Python Type |
|---|---|---|
str |
Any non-empty string, excluding /
|
str |
int |
Zero or positive integer | int |
slug |
ASCII letters, numbers, hyphens, underscores | str |
uuid |
UUID-formatted string | uuid.UUID |
path |
Any non-empty string, including /
|
str |
re_path(): Regex-Powered Flexibility
For patterns that path() can't express cleanly, re_path() accepts a full Python regular expression. The re_path() function allows for more complex URL patterns using regular expressions, giving greater flexibility at the cost of readability.
from django.urls import re_path
# Named groups capture parameters as keyword arguments
re_path(r"^articles/(?P<year>[0-9]{4})/(?P<month>[0-9]{2})/$", archive_view)
include(): Modular URL Composition
include() lets you break URL patterns across multiple files, keeping each app responsible for its own routing:
from django.urls import path, include
urlpatterns = [
path("blog/", include("apps.blog.urls", namespace="blog")),
path("api/v1/", include("apps.api.v1.urls", namespace="api_v1")),
path("api/v2/", include("apps.api.v2.urls", namespace="api_v2")),
]
URL Namespaces: The Key to Scalable Routing
Namespaces allow you to use the same URL names in multiple apps without collision. They have two levels:
-
Application namespace (
app_name): The name of the app providing the URLs — set in the app'surls.py -
Instance namespace (
namespace): The specific deployment of the app — set ininclude()
# Reverse with namespace
reverse("blog:post-detail", kwargs={"slug": "my-post"})
# → "/blog/posts/my-post/"
# In templates
{% url "blog:post-detail" slug=post.slug %}
reverse() and reverse_lazy()
reverse() converts a URL name (and optional arguments) back into a URL string. This is the canonical way to reference URLs in Django code — never hardcode strings.
from django.urls import reverse, reverse_lazy
# At runtime (in views, services)
url = reverse("orders:detail", kwargs={"pk": 42})
# → "/api/orders/42/"
# At class-definition time (in CBV attributes, can't use reverse() directly)
class OrderCreateView(CreateView):
success_url = reverse_lazy("orders:list")
reverse_lazy() is a lazily evaluated version of reverse(). It is useful for when you need to use a URL reversal before your project's URLconf is loaded — such as in class-level attributes.
Architecture Design
The Resolution Pipeline in Detail
HTTP Request: GET /api/v2/orders/42/?format=json
│
▼
settings.ROOT_URLCONF = "config.urls"
│
▼
┌───────────────────────────────────────────────────┐
│ config/urls.py │
│ │
│ urlpatterns = [ │
│ path("admin/", admin.site.urls), │
│ path("api/v1/", include("apps.api.v1.urls")),│
│ path("api/v2/", include("apps.api.v2.urls")),│ ← matched
│ ] │
└──────────────────────┬────────────────────────────┘
│ remaining path: "orders/42/"
▼
┌───────────────────────────────────────────────────┐
│ apps/api/v2/urls.py │
│ │
│ urlpatterns = [ │
│ path("orders/", OrderListView...), │
│ path("orders/<int:pk>/", OrderDetailView...), │ ← matched
│ path("products/", ProductListView...), │
│ ] │
└──────────────────────┬────────────────────────────┘
│ pk=42 extracted and converted to int
▼
ResolverMatch(
func=OrderDetailView.as_view(),
args=(),
kwargs={"pk": 42},
url_name="order-detail",
app_name="api_v2",
namespace="api_v2",
route="api/v2/orders/<int:pk>/",
)
│
▼
OrderDetailView.as_view()(request, pk=42)
Production URL Architecture for a Multi-App Project
config/
└── urls.py ← ROOT_URLCONF: top-level routing only
apps/
├── users/
│ └── urls.py ← /api/users/* routes
├── catalog/
│ └── urls.py ← /api/catalog/* routes
├── orders/
│ └── urls.py ← /api/orders/* routes
└── api/
├── v1/
│ └── urls.py ← /api/v1/* (legacy version)
└── v2/
└── urls.py ← /api/v2/* (current version)
URL Hierarchy:
/
├── admin/ → Django admin
├── api/
│ ├── v1/
│ │ ├── users/ → apps.api.v1.users.urls
│ │ ├── products/ → apps.api.v1.catalog.urls
│ │ └── orders/ → apps.api.v1.orders.urls
│ └── v2/
│ ├── users/ → apps.api.v2.users.urls
│ ├── catalog/ → apps.api.v2.catalog.urls
│ └── orders/ → apps.api.v2.orders.urls
├── auth/
│ ├── login/
│ ├── logout/
│ └── password/reset/
└── health/ → Health check endpoint (no auth)
Step-by-Step Implementation
Step 1: Root URL Configuration
# config/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
# Custom error handlers — must be at root URLconf level
handler400 = "common.views.bad_request"
handler403 = "common.views.permission_denied"
handler404 = "common.views.page_not_found"
handler500 = "common.views.server_error"
urlpatterns = [
# Admin — use a non-default path in production
path(settings.ADMIN_URL, admin.site.urls),
# Health check — unauthenticated, before any other routing
path("health/", include("apps.health.urls")),
# Authentication
path("auth/", include("apps.users.auth_urls", namespace="auth")),
# API versions
path("api/v1/", include("apps.api.v1.urls", namespace="api_v1")),
path("api/v2/", include("apps.api.v2.urls", namespace="api_v2")),
# Web interface (server-rendered)
path("", include("apps.web.urls", namespace="web")),
]
# Serve media files in development only
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# django-debug-toolbar
import debug_toolbar
urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns
Step 2: App-Level URL Files with Namespaces
# apps/orders/urls.py
from django.urls import path
from apps.orders.views import (
OrderListView, OrderDetailView, OrderCreateView,
OrderUpdateView, OrderCancelView, OrderItemView,
)
# app_name sets the APPLICATION namespace — required for namespaced reverse()
app_name = "orders"
urlpatterns = [
# Collection endpoints
path("", OrderListView.as_view(), name="list"),
path("create/", OrderCreateView.as_view(), name="create"),
# Instance endpoints
path("<int:pk>/", OrderDetailView.as_view(), name="detail"),
path("<int:pk>/update/", OrderUpdateView.as_view(), name="update"),
path("<int:pk>/cancel/", OrderCancelView.as_view(), name="cancel"),
# Nested resource: items within an order
path("<int:order_pk>/items/<int:pk>/", OrderItemView.as_view(), name="item-detail"),
]
Step 3: Custom Path Converters
Path converters are the cleanest way to handle non-standard URL parameters. They're type-safe, reusable, and eliminate repetitive validation in views:
# common/converters.py
import uuid
import re
class UUIDConverter:
"""
Matches a UUID in standard hyphenated form.
Automatically converts to/from uuid.UUID.
Usage: path("resources/<uuid:pk>/", view)
"""
regex = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
def to_python(self, value: str) -> uuid.UUID:
return uuid.UUID(value)
def to_url(self, value: uuid.UUID) -> str:
return str(value)
class FourDigitYearConverter:
"""
Matches a four-digit year and converts to int.
Usage: path("archive/<year:year>/", view)
"""
regex = "[0-9]{4}"
def to_python(self, value: str) -> int:
return int(value)
def to_url(self, value: int) -> str:
return f"{value:04d}"
class PositiveIntConverter:
"""
Matches a positive integer (1 or greater — no zero, no negatives).
More restrictive than the built-in int converter.
Usage: path("pages/<posint:page>/", view)
"""
regex = "[1-9][0-9]*"
def to_python(self, value: str) -> int:
return int(value)
def to_url(self, value: int) -> str:
if value < 1:
raise ValueError(f"PositiveIntConverter requires value >= 1, got {value}")
return str(value)
# config/urls.py
from django.urls import register_converter
from common.converters import UUIDConverter, FourDigitYearConverter, PositiveIntConverter
# Register before urlpatterns is defined
register_converter(UUIDConverter, "uuid")
register_converter(FourDigitYearConverter, "year")
register_converter(PositiveIntConverter, "posint")
urlpatterns = [
# Now usable in any urls.py across the project
path("resources/<uuid:pk>/", ResourceView.as_view()),
path("archive/<year:year>/", ArchiveView.as_view()),
path("articles/<slug:slug>/page/<posint:page>/", ArticlePageView.as_view()),
]
Step 4: Error Handler Views
# common/views.py
from django.shortcuts import render
from django.http import (
HttpResponseBadRequest, HttpResponseForbidden,
HttpResponseNotFound, HttpResponseServerError,
)
import logging
logger = logging.getLogger(__name__)
def bad_request(request, exception=None):
"""400 Bad Request"""
return render(request, "errors/400.html", status=400)
def permission_denied(request, exception=None):
"""403 Forbidden"""
return render(request, "errors/403.html", status=403)
def page_not_found(request, exception=None):
"""
404 Not Found.
Log only in DEBUG to avoid log flooding from bots and scanners.
"""
if not request.path.startswith("/api/"):
logger.debug("404: %s", request.path)
return render(request, "errors/404.html", status=404)
def server_error(request):
"""
500 Internal Server Error.
Note: no access to request.user here — middleware may not have run.
"""
logger.error("500 on %s", request.path)
return render(request, "errors/500.html", status=500)
# config/urls.py — error handlers must be at root level
handler400 = "common.views.bad_request"
handler403 = "common.views.permission_denied"
handler404 = "common.views.page_not_found"
handler500 = "common.views.server_error"
Code Examples
Versioned API URL Structure
# apps/api/v1/urls.py
from django.urls import path, include
app_name = "api_v1"
urlpatterns = [
path("users/", include("apps.api.v1.users.urls", namespace="users")),
path("catalog/", include("apps.api.v1.catalog.urls", namespace="catalog")),
path("orders/", include("apps.api.v1.orders.urls", namespace="orders")),
]
# apps/api/v2/urls.py
from django.urls import path, include
app_name = "api_v2"
urlpatterns = [
path("users/", include("apps.api.v2.users.urls", namespace="users")),
path("catalog/", include("apps.api.v2.catalog.urls", namespace="catalog")),
path("orders/", include("apps.api.v2.orders.urls", namespace="orders")),
# v2-only endpoints
path("analytics/", include("apps.api.v2.analytics.urls", namespace="analytics")),
]
# Reversing versioned URLs
from django.urls import reverse
# v1 URL
v1_order_url = reverse("api_v1:orders:detail", kwargs={"pk": 42})
# → "/api/v1/orders/42/"
# v2 URL — same name, different namespace
v2_order_url = reverse("api_v2:orders:detail", kwargs={"pk": 42})
# → "/api/v2/orders/42/"
The resolve() Function: URL Introspection
from django.urls import resolve, Resolver404
# Inspect what view a URL maps to
try:
match = resolve("/api/v2/orders/42/")
print(match.func) # <function OrderDetailView.as_view.<locals>.view>
print(match.url_name) # "detail"
print(match.namespace) # "api_v2"
print(match.namespaces) # ["api_v2", "orders"]
print(match.kwargs) # {"pk": 42}
print(match.route) # "api/v2/orders/<int:pk>/"
except Resolver404:
print("URL does not match any pattern")
# Practical use: test-time URL validation
from django.test import TestCase
class URLResolutionTests(TestCase):
def test_order_detail_resolves(self):
match = resolve("/api/v2/orders/42/")
self.assertEqual(match.url_name, "detail")
self.assertEqual(match.kwargs, {"pk": 42})
def test_order_detail_reverses(self):
url = reverse("api_v2:orders:detail", kwargs={"pk": 42})
self.assertEqual(url, "/api/v2/orders/42/")
Extra kwargs: Passing Context Through URL Patterns
# Pass extra arguments to views without putting them in the URL
# Useful for: feature flags, view variants, shared views with different context
urlpatterns = [
# Same view, different context passed via kwargs
path(
"reports/monthly/",
ReportView.as_view(),
kwargs={"report_type": "monthly"},
name="report-monthly",
),
path(
"reports/annual/",
ReportView.as_view(),
kwargs={"report_type": "annual"},
name="report-annual",
),
]
# In the view — extra kwargs arrive as keyword arguments
class ReportView(LoginRequiredMixin, TemplateView):
template_name = "reports/report.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
# report_type comes from URL pattern kwargs, not URL capture
ctx["report_type"] = kwargs["report_type"]
return ctx
Internationalized URL Patterns (i18n_patterns)
# config/urls.py
from django.conf.urls.i18n import i18n_patterns
from django.utils.translation import gettext_lazy as _
urlpatterns = [
path("api/", include("apps.api.urls")), # API: no language prefix
]
# Web pages: prefixed with language code (/en/, /fr/, /de/)
urlpatterns += i18n_patterns(
path(_("products/"), include("apps.catalog.urls", namespace="catalog")),
path(_("orders/"), include("apps.orders.urls", namespace="orders")),
path(_("account/"), include("apps.users.urls", namespace="users")),
prefix_default_language=True, # /en/products/ even for default language
)
# URL examples after i18n_patterns:
# /en/products/my-widget/
# /fr/produits/mon-widget/
# /de/produkte/mein-widget/
URL Testing: Comprehensive Test Suite
# tests/test_urls.py
from django.test import TestCase, Client
from django.urls import reverse, resolve, NoReverseMatch
from django.contrib.auth import get_user_model
User = get_user_model()
class URLResolutionTests(TestCase):
"""
Verify that all named URLs resolve and reverse correctly.
Run on every CI build to catch URL regressions before production.
"""
def test_order_list_resolves(self):
url = "/api/v2/orders/"
match = resolve(url)
self.assertEqual(match.url_name, "list")
self.assertEqual(match.namespace, "orders")
def test_order_detail_resolves_with_pk(self):
url = "/api/v2/orders/99/"
match = resolve(url)
self.assertEqual(match.url_name, "detail")
self.assertEqual(match.kwargs, {"pk": 99})
def test_order_detail_reverses(self):
url = reverse("api_v2:orders:detail", kwargs={"pk": 99})
self.assertEqual(url, "/api/v2/orders/99/")
def test_invalid_pk_type_does_not_resolve(self):
"""Non-integer pk should return 404, not match the int converter."""
response = Client().get("/api/v2/orders/not-an-integer/")
self.assertEqual(response.status_code, 404)
def test_health_check_requires_no_auth(self):
response = Client().get("/health/")
self.assertEqual(response.status_code, 200)
def test_admin_uses_custom_path(self):
"""Admin should NOT be at /admin/ — verify custom path is in use."""
from django.conf import settings
self.assertNotEqual(settings.ADMIN_URL, "admin/")
response = Client().get("/admin/")
self.assertEqual(response.status_code, 404)
DRF Router Integration
When using Django REST Framework, routers auto-generate URL patterns from ViewSets. They integrate cleanly with Django's URL system:
# apps/api/v2/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.orders.viewsets import OrderViewSet
from apps.catalog.viewsets import ProductViewSet, CategoryViewSet
app_name = "api_v2"
router = DefaultRouter()
router.register("orders", OrderViewSet, basename="order")
router.register("products", ProductViewSet, basename="product")
router.register("categories", CategoryViewSet, basename="category")
# Generated URLs:
# GET/POST /api/v2/orders/ → order-list
# GET/PUT/DELETE /api/v2/orders/{pk}/ → order-detail
# + same for products, categories
urlpatterns = [
path("", include(router.urls)),
# Add non-RESTful action endpoints alongside the router
path("orders/<int:pk>/cancel/", OrderCancelView.as_view(), name="order-cancel"),
path("orders/<int:pk>/invoice/", OrderInvoiceView.as_view(), name="order-invoice"),
]
Conditional URL Loading by Environment
# config/urls.py
from django.conf import settings
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("apps.api.urls")),
]
if settings.DEBUG:
# Development-only tools
import debug_toolbar
urlpatterns = [
path("__debug__/", include(debug_toolbar.urls)),
] + urlpatterns
# Serve uploaded files locally
from django.conf.urls.static import static
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if settings.ENVIRONMENT == "testing":
# Test-only views for simulating external services
urlpatterns += [
path("mock/stripe/", include("tests.mocks.stripe_urls")),
]
Performance Optimization
1. URL Pattern Compilation and Caching
Django processes URL patterns in the urlpatterns list which is compiled the first time it's accessed. Subsequent requests use the cached configuration. This means pattern compilation cost is a one-time startup overhead — not per-request. You cannot warm this cache yourself, but you can ensure it's as lean as possible.
What you can optimize:
- Keep
re_path()patterns as simple as possible — complex regexes are compiled at startup and matched on every request - Prefer
path()overre_path()— path converters use lighter matching logic than full regex - Avoid deeply nested
include()chains that force the resolver to load and cache many modules at startup
2. Put High-Traffic Patterns First
Django walks urlpatterns in order and stops at the first match. For a system where 80% of requests hit the API:
# BAD — API patterns are deep in the list
urlpatterns = [
path("admin/", admin.site.urls), # rarely hit
path("static/", serve_static), # very rarely
path("webhooks/", include("webhooks.urls")),# rarely hit
path("api/", include("api.urls")), # ← 80% of traffic hits here
]
# GOOD — highest-traffic patterns first
urlpatterns = [
path("api/", include("api.urls")), # ← 80% of traffic → resolved first
path("webhooks/", include("webhooks.urls")), # moderate traffic
path("admin/", admin.site.urls), # low traffic
path("static/", serve_static), # should be handled by Nginx, not Django
]
3. Never Serve Static Files Through Django in Production
# Development: Django serves static/media files (slow, single-threaded)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Production: Nginx handles static files directly, never hitting Django
# config/nginx/myapp.conf:
# location /static/ {
# alias /var/www/myapp/static/;
# expires 1y;
# add_header Cache-Control "public, immutable";
# }
4. Use reverse_lazy() for Class-Level URL References
# BAD — reverse() at class definition time fails if URLconf isn't loaded yet
class OrderCreateView(CreateView):
success_url = reverse("orders:list") # ← ImproperlyConfigured at import time
# GOOD — reverse_lazy() defers evaluation until URL resolution is ready
class OrderCreateView(CreateView):
success_url = reverse_lazy("orders:list") # ← evaluated lazily on first use
5. Test URL Resolution in CI to Catch Circular Imports
URL modules that import from views that import from models at module level can create circular imports that only appear at startup. Make URL resolution a CI step:
# In CI — force Django to load and validate all URLconfs
python manage.py check --deploy
# Or write a management command that calls resolve() on every registered URL
python manage.py validate_urls # see Real Production Use Cases below
Security Best Practices
1. Randomise the Admin URL
Keeping the Django admin at /admin/ invites automated attacks. Move it to a non-guessable path in production:
# config/settings/production.py
ADMIN_URL = env("ADMIN_URL", default="admin/") # Override in environment
# config/settings/.env.production
ADMIN_URL=a7f3k9x2-management-console/
# config/urls.py
path(settings.ADMIN_URL, admin.site.urls),
# Admin is now at: /a7f3k9x2-management-console/
2. Never Expose Sensitive Information in URL Patterns
# BAD — user's email is in the URL; logged by proxies, Nginx, CDNs, browser history
path("users/<str:email>/profile/", profile_view)
# GOOD — use opaque IDs in URLs, look up email internally
path("users/<int:pk>/profile/", profile_view)
# Or better — use UUIDs to prevent enumeration:
path("users/<uuid:pk>/profile/", profile_view)
3. Strict URL Type Validation via Converters
Custom path converters are your first line of defence against malformed input — before it reaches the view:
# BAD — re_path with loose regex accepts unexpected values
re_path(r"^orders/(?P<pk>\d+)/$", order_detail)
# Matches: /orders/0/ (zero), /orders/99999999999/ (overflow)
# GOOD — custom converter rejects invalid values at routing level
class OrderPKConverter:
regex = "[1-9][0-9]{0,9}" # 1–9999999999: no zero, max 10 digits
def to_python(self, value: str) -> int:
return int(value)
def to_url(self, value: int) -> str:
return str(value)
path("orders/<orderpk:pk>/", order_detail)
# /orders/0/ → 404 (no match)
# /orders/9999999999/ → 404 (too many digits)
4. Enforce Trailing Slashes Consistently
Django's APPEND_SLASH = True (the default) automatically redirects /orders/42 to /orders/42/. This is a 301 Permanent Redirect — configure Nginx to handle this before it reaches Django:
# Nginx: normalize trailing slash before proxying to Django
# This saves a round-trip (Django redirect → client → Django again)
rewrite ^([^/]+[^/])$ $1/ permanent;
For APIs where trailing slashes aren't standard, set APPEND_SLASH = False and enforce no-trailing-slash in your URL patterns:
# config/settings/production.py
APPEND_SLASH = False # ← for API-only Django projects
# apps/api/urls.py — no trailing slashes
urlpatterns = [
path("orders", OrderListView.as_view()),
path("orders/<int:pk>", OrderDetailView.as_view()),
]
5. Prevent Path Traversal with Strict Converters
# BAD — str converter matches slashes if path converter is misused
# An attacker could try: /files/../../etc/passwd
path("files/<str:filename>/download/", file_download)
# GOOD — restrict to safe characters explicitly
class SafeFilenameConverter:
regex = r"[a-zA-Z0-9_\-]{1,64}" # letters, numbers, dash, underscore; max 64 chars
def to_python(self, value: str) -> str:
return value
def to_url(self, value: str) -> str:
return value
path("files/<filename:filename>/download/", file_download)
Common Developer Mistakes
❌ Mistake 1: Hardcoding URLs Instead of Using reverse()
# BAD — brittle; breaks silently when URL changes
return redirect("/orders/42/")
return render(request, "template.html", {"url": "/api/v2/orders/"})
# GOOD — survives URL pattern changes
return redirect(reverse("orders:detail", kwargs={"pk": 42}))
return render(request, "template.html", {
"url": reverse("api_v2:orders:list")
})
# In templates — never hardcode
# BAD: <a href="/orders/{{ order.pk }}/">View Order</a>
# GOOD: <a href="{% url 'orders:detail' pk=order.pk %}">View Order</a>
❌ Mistake 2: Forgetting app_name for Namespaced URLs
# BAD — include() with namespace= but no app_name in urls.py
# This raises an ImproperlyConfigured exception
# config/urls.py
path("orders/", include("apps.orders.urls", namespace="orders")) # ← will fail!
# apps/orders/urls.py — MISSING app_name!
urlpatterns = [...] # no app_name attribute
# GOOD — app_name in urls.py matches the namespace
# apps/orders/urls.py
app_name = "orders" # ← required
urlpatterns = [...]
❌ Mistake 3: Using reverse() in Class-Level Attributes
# BAD — runs at class definition time, before URLconf loads
class OrderView(CreateView):
success_url = reverse("orders:list") # ← ImproperlyConfigured
# GOOD — deferred evaluation
class OrderView(CreateView):
success_url = reverse_lazy("orders:list") # ← safe
❌ Mistake 4: Regex Anchors in path() Patterns
# BAD — path() does NOT use regex, but devs copy old regex patterns
path("^articles/$", article_list) # ← matches literally "^articles/$"!
path(r"^orders/(?P<pk>\d+)/$", ...) # ← same problem
# Django even warns you about this:
# 2_0.W001: Your URL pattern has a route that contains '(?P<'
# or begins with '^' or ends with '$'
# GOOD — path() syntax, no anchors
path("articles/", article_list)
path("orders/<int:pk>/", order_detail)
❌ Mistake 5: Including a URLconf with a Trailing $ in re_path
# BAD — trailing $ in include() breaks sub-pattern matching
re_path(r"^blog/$", include("apps.blog.urls"))
# Nothing inside blog.urls will ever match — the $ anchors before include()
# GOOD — no trailing $ on include() patterns
re_path(r"^blog/", include("apps.blog.urls"))
❌ Mistake 6: Putting All URLs in the Root urls.py
# BAD — 200 URL patterns in one file; no namespacing; no modularity
# config/urls.py
urlpatterns = [
path("orders/", order_list),
path("orders/<int:pk>/", order_detail),
path("orders/create/", order_create),
# ... 197 more patterns
]
# GOOD — each app owns its URLs
# config/urls.py
urlpatterns = [
path("orders/", include("apps.orders.urls", namespace="orders")),
path("catalog/", include("apps.catalog.urls", namespace="catalog")),
]
Real Production Use Cases
Use Case 1: Management Command to Validate All URLs
# apps/core/management/commands/validate_urls.py
from django.core.management.base import BaseCommand
from django.urls import get_resolver
class Command(BaseCommand):
help = "Validates that all registered URL patterns are importable and callable."
def handle(self, *args, **options):
resolver = get_resolver()
errors = []
valid = 0
def check_patterns(resolver, prefix=""):
for pattern in resolver.url_patterns:
full_pattern = prefix + str(pattern.pattern)
if hasattr(pattern, "url_patterns"):
# It's an include() — recurse
check_patterns(pattern, full_pattern)
else:
# It's an endpoint — verify the view is callable
try:
callback = pattern.callback
assert callable(callback), f"Not callable: {callback}"
nonlocal valid
valid += 1
except Exception as e:
errors.append(f"FAIL {full_pattern}: {e}")
check_patterns(resolver)
if errors:
for err in errors:
self.stderr.write(self.style.ERROR(err))
raise SystemExit(1)
else:
self.stdout.write(self.style.SUCCESS(f"✓ {valid} URL patterns validated."))
Use Case 2: Multi-Tenant URL Routing
# config/urls.py
from django.urls import path, include
def tenant_url_patterns():
"""
Returns URL patterns for tenant-specific routing.
Each tenant gets the same URL structure but isolated data via middleware.
"""
return [
path("", include("apps.dashboard.urls", namespace="dashboard")),
path("api/", include("apps.api.urls", namespace="api")),
path("settings/", include("apps.settings.urls", namespace="settings")),
]
urlpatterns = [
# Shared paths — no tenant context required
path("health/", include("apps.health.urls")),
path("auth/", include("apps.auth.urls", namespace="auth")),
# Tenant-scoped paths — TenantMiddleware resolves tenant from hostname
path("", include(tenant_url_patterns())),
]
Use Case 3: API Versioning with Router Composition
# apps/api/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.orders.viewsets import OrderViewSetV1, OrderViewSetV2
from apps.catalog.viewsets import ProductViewSetV1, ProductViewSetV2
# v1 Router — legacy, maintained for backward compatibility
v1_router = DefaultRouter()
v1_router.register("orders", OrderViewSetV1, basename="order")
v1_router.register("products", ProductViewSetV1, basename="product")
# v2 Router — current, with additional resources
v2_router = DefaultRouter()
v2_router.register("orders", OrderViewSetV2, basename="order")
v2_router.register("products", ProductViewSetV2, basename="product")
v2_router.register("analytics", AnalyticsViewSet, basename="analytic")
urlpatterns = [
path("v1/", include((v1_router.urls, "api_v1"))),
path("v2/", include((v2_router.urls, "api_v2"))),
]
Use Case 4: Internationalised Routing for a Global Platform
# config/urls.py for a multilingual e-commerce platform
from django.conf.urls.i18n import i18n_patterns
from django.urls import path, include
# Language-agnostic: API and webhooks
urlpatterns = [
path("api/", include("apps.api.urls")),
path("webhooks/", include("apps.webhooks.urls")),
path("health/", include("apps.health.urls")),
]
# Language-prefixed: all customer-facing web pages
# URLs are translated using gettext_lazy in each app's urls.py
from django.utils.translation import gettext_lazy as _
urlpatterns += i18n_patterns(
path("", include("apps.home.urls", namespace="home")),
path(_("products/"), include("apps.catalog.urls", namespace="catalog")),
path(_("cart/"), include("apps.cart.urls", namespace="cart")),
path(_("checkout/"), include("apps.checkout.urls", namespace="checkout")),
path(_("my-account/"), include("apps.users.urls", namespace="users")),
prefix_default_language=True,
)
# Result:
# English: /en/products/, /en/cart/, /en/checkout/
# French: /fr/produits/, /fr/panier/, /fr/commande/
# German: /de/produkte/, /de/warenkorb/, /de/kasse/
Conclusion
Django's URL routing system is deeper than most developers realise, and the gap between "knows the basics" and "uses it well in production" is where real application quality lives.
The key principles from this guide:
Use path() by default, re_path() only when necessary. path() is more readable, type-safe through converters, and produces better error messages. Reach for re_path() only when a built-in or custom converter can't express your pattern.
Always use URL names and reverse()/reverse_lazy(). Hardcoded URL strings are a maintenance trap. One URL pattern change should require changing exactly one file — the urls.py that defines the pattern. reverse() enforces this discipline.
Use namespaces for every app. Without namespaces, URL names collide across apps. With namespaces, reverse("orders:detail") and reverse("invoices:detail") are unambiguous, even if both apps define a detail URL.
Build custom converters for non-standard parameters. They're type-safe, reusable, testable in isolation, and move validation out of views into the routing layer where it belongs.
Put high-traffic patterns first, and never serve static files through Django in production. Pattern ordering has real performance implications. Static files should never reach Gunicorn.
Treat URL exposure as a security surface. Randomise the admin URL, use UUIDs instead of sequential IDs for resource identifiers, and restrict converter patterns to exactly the input shapes your views expect.
URL routing is the first decision your application makes about every HTTP request. Make it deliberately.
Further Reading
- Django URL Dispatcher Documentation
- Django URL Functions Reference
- URL Namespaces — Django Docs
- Custom Path Converters
- Internationalisation: URL Patterns
- django-debug-toolbar URL Configuration
- DRF Routers
Written by a Python backend engineer building production Django systems. Topics: Django URL routing, path(), re_path(), include(), reverse(), namespaces, custom converters, i18n_patterns, API versioning, URL security.
Top comments (0)