5.3 Resolving a path
How URLResolver searches the tree for a path: the order it goes in, how the arguments found at each level are merged into one ResolverMatch, what a Resolver404 carries, and what asks the resolver besides the handler.
Everything that resolves a path ends in one method, URLResolver, called on a root resolver. BaseHandler calls it with the request’s path_info, for each request that reaches the centre of the middleware chain. The function resolve calls it for anyone else: it takes a path and, optionally, a URLconf, and without one it uses the current URLconf of the thread or task it is called in, or failing that the root one. is_valid_path is resolve with the exception turned into False.
What is resolved is a path and nothing more: no host, no method, no query string. It is the path as path_info has it, without the script prefix and with its percent escapes already decoded (Making the request: WSGIRequest and ASGIRequest), so a route is written for the characters themselves. Asked directly for a path with a space in it, the museum’s pattern for files takes the space as a space:
resolve('/files/maps/ground floor.pdf') -> views.download, args (), kwargs {'name': 'maps/ground floor.pdf'}
The search
URLResolver is the same method at every level of the tree, the root included.
It first asks its own matcher about the path. If the matcher refuses, the method raises Resolver404 and is done. If it accepts, it has returned what is left of the path and what it captured, and the method goes through url_patterns in order, calling resolve on each entry with what is left. An entry is a pattern, whose URLPattern returns a match or None, or another resolver, whose resolve is this same method one level down. The first entry to produce a match ends the search.
A resolver that is an entry of another says no by raising. Its resolve raises Resolver404 when its own matcher refuses and when none of its entries matches, and the resolver above catches the exception, notes what was tried, and moves to its next entry. Resolver404 is thus used inside the tree as well as at its top: below, it is how a branch reports that the path is not under it, and from the root it is the answer for a path that matches nothing.
This request was for a month the museum’s converter refuses. Every entry of the root’s list is asked, and after the last the root raises.
GET /tickets/2026/13/: a converter refuses, and nothing else matches
BaseHandler.resolve_request
get_resolver()
URLResolver.resolve('/tickets/2026/13/') (the root resolver)
RegexPattern.match('/tickets/2026/13/') -> ('tickets/2026/13/', (), {})
URLPattern.resolve('tickets/2026/13/') ('') -> None
URLPattern.resolve('tickets/2026/13/') ('tickets/<int:year>/') -> None
URLPattern.resolve('tickets/2026/13/') ('tickets/<int:year>/<month:month>/')
RoutePattern.match('tickets/2026/13/')
IntConverter.to_python('2026') -> 2026
MonthConverter.to_python('13') raises ValueError
-> None
-> None
URLResolver.resolve('tickets/2026/13/') ('east/') raises Resolver404
URLResolver.resolve('tickets/2026/13/') ('west/') raises Resolver404
URLResolver.resolve('tickets/2026/13/') ('shop/') raises Resolver404
URLPattern.resolve('tickets/2026/13/') ('^plans/(?P<floor>[0-9])(?:\\.(?P<format>pdf|svg))?$') -> None
URLPattern.resolve('tickets/2026/13/') ('files/<path:name>') -> None
raises Resolver404
response_for_exception
get_resolver('museum.urls')
URLResolver.resolve_error_handler(404) (the root resolver) -> defaults.page_not_found
start_response("404 Not Found", headers)
The three resolvers among the root’s entries each raised at once, because none of their routes is the start of this path. The last lines belong to the handler. The exception left resolve_request, the wrapper around the centre of the middleware chain passed it to response_for_exception, and that function, finding an Http404, went back to the resolver for the 404 error view (How an exception becomes a response).
Getting into an include does not commit the search to it. When a resolver’s own route matches and nothing in its list does, the resolver above treats that as it treats any other refusal, and carries on with the entries that follow. Here a path gets into the shop’s include, fails there, and is matched by the entry after it:
A path that gets into an include, matches nothing there, and is matched by a later entry
RETURNS is a URLconf of two entries:
path("shop/", include([path("", views.shop, name="shop"), path("<slug:item>/", views.item, name="item")]))
path("shop/<slug:item>/closed/", views.item, name="closed")
the script calls resolve("/shop/poster/closed/", urlconf=RETURNS)
resolve('/shop/poster/closed/', urlconf=a tuple of 2 entries)
get_resolver(a tuple of 2 entries)
URLResolver.resolve('/shop/poster/closed/') (the root resolver of the URLconf given)
RegexPattern.match('/shop/poster/closed/') -> ('shop/poster/closed/', (), {})
URLResolver.resolve('shop/poster/closed/') ('shop/')
RoutePattern.match('shop/poster/closed/') -> ('poster/closed/', (), {})
URLPattern.resolve('poster/closed/') ('') -> None
URLPattern.resolve('poster/closed/') ('<slug:item>/') -> None
raises Resolver404
URLPattern.resolve('shop/poster/closed/') ('shop/<slug:item>/closed/')
RoutePattern.match('shop/poster/closed/')
StringConverter.to_python('poster') (SlugConverter) -> 'poster'
-> ('', (), {'item': 'poster'})
-> ResolverMatch(views.item, kwargs={'item': 'poster'}, namespaces=[], route='shop/<slug:item>/closed/')
-> ResolverMatch(views.item, kwargs={'item': 'poster'}, namespaces=[], route='shop/<slug:item>/closed/')
-> ResolverMatch(views.item, kwargs={'item': 'poster'}, namespaces=[], route='shop/<slug:item>/closed/')
How the arguments are merged
A match is found at a pattern and returned through every resolver above it, and each level adds what it knows. Where two levels have a keyword argument of the same name, the one applied later replaces the earlier, in this order:
- what a resolver’s own route captured;
- the extra arguments given where that resolver was included, its
URLResolver;.default_kwargs - the same two for each resolver further down, in turn;
- what the pattern’s route captured;
- the pattern’s own extra arguments, its
default_args.
So an extra argument beats a captured one of the same name at its own level, and anything further down beats anything further up. URLPattern applies the last step as it makes the first ResolverMatch; each resolver on the way up makes a new match, starting from its own two and laying the match from below over them.
OVERRIDES is path("<slug:wing>/", include([path("rooms/<int:room>/", views.room, {"room": 1}, name="room")]), {"wing": "annexe"})
resolve('/east/rooms/12/', urlconf=OVERRIDES) -> views.room, args (), kwargs {'wing': 'annexe', 'room': 1}
NESTED is path("<slug:wing>/", include([path("<slug:wing>/", views.wing, name="wing")]))
resolve('/east/west/', urlconf=NESTED) -> views.wing, args (), kwargs {'wing': 'west'}
In the first of these the path says east and room 12, and the view is given the annexe and room 1: at both levels the extra argument replaced the capture. In the second both levels capture a wing, and the view gets the inner one.
Positional arguments, which only a re_path with unnamed groups produces, are passed up as they are. What a resolver’s own matcher captured by position is put in front of them when the merged keyword arguments are empty, and is dropped when they are not:
POSITIONAL is a URLconf of two entries:
re_path(r"^a/([0-9]+)/", include([re_path(r"^b/([0-9]+)/$", views.tickets, name="b")]))
re_path(r"^c/([0-9]+)/", include([re_path(r"^d/([0-9]+)/$", views.tickets, {"month": 12}, name="d")]))
resolve('/a/1/b/2/', urlconf=POSITIONAL) -> views.tickets, args ('1', '2'), kwargs {}
resolve('/c/1/d/2/', urlconf=POSITIONAL) -> views.tickets, args ('2',), kwargs {'month': 12}
Each resolver also puts its application namespace and its instance namespace at the front of the lists the match carries, and builds up the route. The route is assembled one level late: a resolver puts in front the route of the resolver below it that matched, not its own. That is why, in the recording on the chapter’s opening page, the east wing’s match already has its namespace and still has only the pattern’s route.
A ResolverMatch, attribute by attribute
This is the match for the address of an audio track, three levels down: the east wing, a room’s audio guide, a track.
A ResolverMatch, attribute by attribute
match = resolve("/east/rooms/12/audio/intro/")
match.func -> views.track
match.args -> ()
match.kwargs -> {'wing': 'east', 'room': 12, 'track': 'intro'}
match.url_name -> 'track'
match.route -> 'east/rooms/<int:room>/audio/<slug:track>/'
match.app_names -> ['gallery', 'audio']
match.app_name -> 'gallery:audio'
match.namespaces -> ['east', 'audio']
match.namespace -> 'east:audio'
match.view_name -> 'east:audio:track'
match.captured_kwargs -> {'track': 'intro'}
match.extra_kwargs -> {'wing': 'east'}
match._func_path -> 'museum.views.track'
match.tried -> a list of the chains tried, the last being the one that matched:
''
'tickets/<int:year>/'
'tickets/<int:year>/<month:month>/'
'east/' > ''
'east/' > 'rooms/<int:room>/'
'east/' > 'rooms/<int:room>/audio/' > '<slug:track>/'
view, args, kwargs = match -> views.track, (), {'wing': 'east', 'room': 12, 'track': 'intro'}
pickle.dumps(match) -> raises PicklingError: Cannot pickle ResolverMatch.
The first three attributes are what the handler needs, and the rest describe how the match came about.
ResolverMatch beyond the view and its arguments.| attribute | what it is |
|---|---|
Resolver |
the name of the pattern that matched, or None |
Resolver |
the text of each matcher on the way down, below the root resolver’s own, joined: a route as written, a regular expression as written, a language prefix as it reads at the moment. Where a resolver’s text is put in front and is not empty, a ^ at the start of what follows it is taken off |
Resolver, Resolver |
the application namespaces and the instance namespaces of the resolvers on the way down, outermost first, empty ones left out |
Resolver, Resolver |
the same two lists, each joined with colons |
Resolver |
the instance namespaces and the pattern’s name joined with colons, which is how a name is written for reverse; where the pattern has no name, the view’s dotted name stands in its place |
Resolver |
what the matched pattern’s own route captured |
Resolver |
the extra arguments, from the pattern and from every include above it |
Resolver |
the chains of entries tried, as in a Resolver404, ending with the one that matched |
The two dictionaries do not add up to kwargs. In the recording the room number is in kwargs and in neither of the others, because it was captured by an include’s route, and captured_kwargs holds the pattern’s captures only.
The handler unpacks a match as if it were a tuple of three: ResolverMatch serves the view, the positional arguments and the keyword arguments by index. It keeps the match on the request, as HttpRequest, where the middleware’s process_view hooks, the view and the template can read it. A ResolverMatch refuses to be pickled: ResolverMatch raises pickle.
What a Resolver404 carries
The exception is made with one argument, a dictionary. A resolver whose own matcher refused puts in only "path", the text it was given. A resolver whose entries all failed puts in "path", the text that was left for them, and "tried": a list of chains, each a list that starts with one of this resolver’s entries and leads down to the pattern or the resolver that said no. An entry that is a pattern, or a resolver whose own matcher refused, adds one chain holding itself alone. An entry that is a resolver whose list failed adds each chain it reported, with itself put at the front.
What a Resolver404 carries: the path that was left, and each chain of entries tried
resolve('/east/rooms/12') raises Resolver404 with {'tried': ..., 'path': 'east/rooms/12'}, where 'tried' is
''
'tickets/<int:year>/'
'tickets/<int:year>/<month:month>/'
'east/' > ''
'east/' > 'rooms/<int:room>/'
'east/' > 'rooms/<int:room>/audio/'
'west/'
'shop/'
'^plans/(?P<floor>[0-9])(?:\\.(?P<format>pdf|svg))?$'
'files/<path:name>'
resolve('east/rooms/12/') raises Resolver404 with {'path': 'east/rooms/12/'}
The first path got into the east wing’s resolver and failed there, so that one entry of the root contributed three chains of two links. The second has no leading slash, the root’s own matcher refused, and nothing was tried at all.
Within Django "tried" has one reader, the debug page. With settings.DEBUG on, the response to a 404 is made by technical_404_response, which takes the list out of the exception and shows each chain as a line, the entries Django tried in the order it tried them. Two lists get the welcome page of a new project instead: an empty one, and, for the path "/", one whose only chain is a single entry with "admin" as both its application namespace and its instance namespace. When the exception carries no list, as an Http404 raised by a view does not, the function reads the one on the request’s match, which is the use Django makes of the tried that a successful match carries. The 404 view used in production does not look at the dictionary. It gives its template the exception’s message when the exception was raised with a string, and the name of the exception’s class otherwise.
The resolver’s other callers
Nothing remembers the answer for a path. Each call of resolve walks the tree again, and a request can cause more than one.
CommonMiddleware is the usual second caller. When a response comes back to it with status 404, and settings is true, and the path does not end in a slash, it asks whether the path would have matched with one. This was recorded with that middleware in front of the museum:
GET /east/rooms/12, with CommonMiddleware and APPEND_SLASH: the resolver is asked three times
BaseHandler.resolve_request
URLResolver.resolve('/east/rooms/12') (the root resolver) raises Resolver404
CommonMiddleware.process_response (given a 404)
CommonMiddleware.should_redirect_with_slash
is_valid_path('/east/rooms/12', None)
URLResolver.resolve('/east/rooms/12') (the root resolver) raises Resolver404
-> False
is_valid_path('/east/rooms/12/', None)
URLResolver.resolve('/east/rooms/12/') (the root resolver) -> ResolverMatch(views.room, kwargs={'wing': 'east', 'room': 12}, namespaces=['east'], route='east/rooms/<int:room>/')
-> the ResolverMatch
-> True
-> a 301, Location '/east/rooms/12/'
start_response("301 Moved Permanently", headers), with Location: /east/rooms/12/
The resolver was asked three times for one request. The handler’s attempt failed. On the way out CommonMiddleware first checked that the path as it came does not resolve: the method is also called from CommonMiddleware, before anything has been resolved, and here a 404 may equally have come from a view. Then it tried the path with a slash, which matched. The response became a permanent redirect to that address. A view can opt out: before it answers, the method reads an attribute named "should_append_slash" from the matched view, which the decorator no_append_slash sets to false. And when settings.DEBUG is true and the request’s method is "DELETE", "POST", "PUT" or "PATCH", the middleware raises RuntimeError in place of the redirect: its message says that Django cannot redirect to the address with the slash and keep the request’s data.
LocaleMiddleware does much the same for a path that lacks a language’s prefix (Language prefixes and translated routes). The test client resolves each path it requested once more, lazily, to give a test resolver_match on the response (The test client: Client and ClientHandler).