Routing & URL Dispatch: How It Actually Works

Web Framework Internals

Chapter 2 · Routing & URL Dispatch: How It Actually Works

Every request a web framework handles starts the same way: a URL and an HTTP method arrive, and somewhere, something has to decide which piece of your own code actually runs. That "somewhere" is the router. It sounds simple — and for a handful of routes, it genuinely is — but the real mechanics behind it (how a URL gets matched, what happens when two routes could both match, how this stays fast with thousands of routes registered) are worth building for real rather than taking on faith.

The Basic Job: URL + Method In, Handler Out

At its simplest, a router is a lookup table: a list of (pattern, handler) pairs, checked against an incoming path until one matches. A genuinely minimal version needs nothing more than exact string comparison:

routes = {} def add_route(path, handler): routes[path] = handler def dispatch(path): handler = routes.get(path) if handler is None: return '404 Not Found' return handler() add_route('/about', lambda: 'About page') print(dispatch('/about')) # About page print(dispatch('/missing')) # 404 Not Found

This is a real, working router — and it's already enough for a handful of static pages. It falls apart the moment a URL needs to carry real data in it, like a specific user's own ID.

Static vs. Dynamic Segments

/users/42 and /users/107 shouldn't need two separate, hand-registered routes — they should both match one pattern, /users/<id>, with id captured and handed to the view. A real, working version of this needs regular expressions:

import re def compile_pattern(path): # Turn /users/<id> into a real regex with a named capture group regex = re.sub(r'<(\w+)>', r'(?P<\1>[^/]+)', path) return re.compile(f'^{regex}$') pattern = compile_pattern('/users/<id>') match = pattern.match('/users/42') print(match.groupdict()) # {'id': '42'}
Verified directly — the captured value is a plain string, not an integer
match.groupdict() returns {'id': '42'} — the character string '42', not the number 42. Regular expressions have no concept of "this should be a number" on their own; every captured segment comes back as text, whatever it looked like in the URL. A view function that does id + 1 without converting it first crashes with a real TypeError, not because of a bug in the router, but because nothing has told the router that id was ever supposed to be numeric in the first place.

Typed Converters: Closing the Gap Above

The fix is to let a route declare what kind of value a segment should be, not just that a segment exists there at all — and to have the router itself both validate and convert it, once, before the view ever sees it:

CONVERTERS = { 'int': (r'\d+', int), 'str': (r'[^/]+', str), } def compile_typed_pattern(path): parts = [] types = {} for segment in path.split('/'): m = re.match(r'<(\w+):(\w+)>', segment) if m: conv_name, param_name = m.group(1), m.group(2) regex, cast = CONVERTERS[conv_name] parts.append(f'(?P<{param_name}>{regex})') types[param_name] = cast else: parts.append(re.escape(segment)) return re.compile('^' + '/'.join(parts) + '$'), types pattern, types = compile_typed_pattern('/users/<int:id>') for test_path in ['/users/42', '/users/abc']: m = pattern.match(test_path) if m: params = {k: types[k](v) for k, v in m.groupdict().items()} print(test_path, '→', params) else: print(test_path, '→ no match')
Verified directly — /users/42 matches and converts, /users/abc is correctly rejected
/users/42 prints {'id': 42} — a real Python int, cast once by the router itself before the handler ever runs. /users/abc prints no match — the \d+ regex behind int simply never matches non-digit characters, so this request would fall through to a genuine 404 instead of reaching a view function that expected a number and would have crashed on a string. The type conversion isn't a convenience layered on top of routing — it's the router closing a real gap that a plain, untyped capture group leaves wide open.

Registration Order & Specificity: A Real, Documented Gotcha

Two registered routes can genuinely both match the same incoming path. What happens next depends entirely on how the router resolves the conflict — and the most common real answer, first-match-wins linear scanning, has a documented, official warning attached to it. Django's own URL dispatcher works exactly this way: it walks its own list of patterns from top to bottom and stops at the very first one that matches, which is why Django's own real documentation explicitly recommends listing "specific patterns before more general ones."

routes = [ (compile_pattern('/users/<id>'), 'user_detail'), # untyped, matches [^/]+ — registered FIRST (compile_pattern('/users/new'), 'user_create_form'), # registered SECOND ] def resolve(path): for pattern, name in routes: if pattern.match(path): return name return '404' print(resolve('/users/new'))
Verified directly — /users/new never reaches its own view
resolve('/users/new') prints 'user_detail', not 'user_create_form'. /users/<id>'s own untyped pattern ([^/]+, matching any non-slash characters at all) matches the literal word "new" just as readily as it matches a real numeric ID, and because it's registered first, the linear scan stops there — user_create_form is never even checked, let alone reached. This is precisely the real class of bug Django's own documentation is warning developers away from. Registering the exact same two routes in the opposite order fixes it directly: with /users/new checked first, its own literal-string match succeeds before the general <id> pattern ever gets a chance to swallow it.
The typed int converter from the section above would have masked this specific case
Swapping in <int:id> (matching only \d+) instead of the untyped <id> happens to reject "new" on its own, since it contains no digits at all — so this particular ordering mistake would go unnoticed with a typed converter in place. That's not the same as the underlying risk being gone: any other general pattern whose own regex is loose enough to also match a more specific literal path still has the identical problem, typed converter or not. Registration order is a real, load-bearing concern independent of whether individual segments are typed.

Route Matching at Scale: Linear Scan vs. Radix Tree

The router built above checks every registered route, one at a time, until it finds a match — a genuine O(n) cost, where n is the total number of registered routes. For a small app with a few dozen routes, that's irrelevant. For a real, large application with thousands of routes, it starts to matter.

High-performance routers take a genuinely different approach. Go's real httprouter — used internally by several other frameworks — stores its routes in a radix tree (a compressed prefix tree), where routes sharing a common path prefix share the same parent node in the tree. Looking up a route this way costs real, verified O(k) time, where k is the length of the path being looked up — completely independent of how many total routes are registered. A million-route application and a ten-route application cost exactly the same amount to route a request through, as long as the request path itself is the same length.

ApproachReal cost per requestWhere it shows up
Linear scan (ordered list, first match wins)O(n) — grows with the number of registered routesThe router built in this chapter; Django's own real URL resolver
Radix tree / compressed trieO(k) — grows only with the path's own length, independent of route countGo's real httprouter, and other routers built on the same technique
Both are genuinely correct choices, for different real reasons
A linear, ordered list is simpler to reason about, and its own explicit ordering is exactly what makes Django's "specific before general" rule meaningful and predictable in the first place. A radix tree is faster at scale, but loses that simple, order-based mental model — most radix-tree routers, httprouter included, sidestep the resulting ambiguity by refusing to register two routes that could ever conflict, rather than relying on registration order to break the tie.

Named Routes & Reverse Routing

So far, every route this chapter has built goes only one direction: a URL comes in, a handler comes out. Real frameworks also need the reverse operation — given a route's own name, generate the actual URL string for it, so a template or a redirect never has to hardcode a path by hand.

named_routes = {'user_detail': '/users/<int:id>'} def url_for(name, **params): template = named_routes[name] for key, value in params.items(): template = re.sub(f'<\\w+:{key}>', str(value), template) return template print(url_for('user_detail', id=42)) # /users/42
This isn't a convenience feature — it's a real, verified bug-avoidance mechanism
This site's own Personal Catalogue (Django & PostgreSQL) and Premier League Predictor: Django & MySQL courses both found a real, concrete consequence of skipping this: code that hardcodes a path as a literal string (redirect('/gameweeks/7/fixtures/')) breaks silently the moment that app is mounted somewhere other than the site root, while code built through a named-route lookup like url_for() above can be told about the new mount point in exactly one place and have every generated link update automatically. Naming a route once and generating every reference to it through that name, rather than typing the literal path string over and over, is what makes that one-place fix possible at all.

HTTP Method Dispatch

The same URL frequently needs genuinely different behavior depending on the HTTP method — GET /users/42 should return a user's data; DELETE /users/42 should remove them. A real router needs to key on both the path and the method together, not the path alone:

routes = {} def add_route(method, path, handler): routes[(method, path)] = handler def dispatch(method, path): handler = routes.get((method, path)) return handler() if handler else '404 Not Found' add_route('GET', '/users/42', lambda: 'User data') add_route('DELETE', '/users/42', lambda: 'User deleted')

A route that matches the path but not the registered method is a genuinely different situation from no match at all — real HTTP defines a dedicated 405 Method Not Allowed response for exactly this case, distinct from a plain 404. How rigorously (or loosely) individual frameworks actually honor that distinction is one of the real differences Chapter 3 compares directly.

Where This Course Is Headed

Chapter 3 takes every concept built here — static and dynamic segments, typed converters, registration order, named/reverse routing, method dispatch — and compares how Django, Express, Rails, Laravel, and FastAPI each genuinely implement it, including real, documented differences in decorator-based versus explicit-registration versus convention-based routing styles.

Hands-On Exercises

Exercise 1

Extend this chapter's own CONVERTERS dictionary with a real 'slug' type (matching lowercase letters, digits, and hyphens only, e.g. "my-first-post") and verify it correctly matches "hello-world-42" while rejecting "Hello World!".

📄 View solution
Exercise 2

Using this chapter's own untyped <id> router (the [^/]+ version, not the typed <int:id> version), register /users/<id> before /users/new and demonstrate concretely that resolve('/users/new') returns the wrong route name — then fix it by reordering the two registrations and confirm the correct route now resolves.

📄 View solution
Exercise 3

Explain, in your own words, why a radix tree's O(k) lookup cost is independent of the total number of registered routes, while this chapter's own linear-scan router's O(n) cost is not — tracing the explanation back to what each approach actually does with a common path prefix shared by multiple routes.

📄 View solution

Chapter 2 Quick Reference

  • The basic job — match a URL + HTTP method to a handler, or return a 404
  • Dynamic segments — regex capture groups; untyped captures always come back as plain strings, never numbers
  • Typed converters — validate and cast a segment before the handler ever sees it, rejecting the wrong shape outright
  • Registration order — first-match-wins linear routers (Django included) require specific patterns before general ones, per Django's own real documentation
  • Linear scan vs. radix tree — O(n) with route count vs. O(k) with path length alone (httprouter's own real, verified design)
  • Named/reverse routing — generating a URL from a route's name, not a hardcoded string, is exactly what let this site's own Django courses fix a mount-point path bug in one place instead of everywhere
  • Method dispatch — the same path can (and should) route differently per HTTP method, with a real 405 for a path match with no method match
  • Next chapter: Routing compared across Django, Express, Rails, Laravel & FastAPI