URL Dispatch
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Pyramid 2.1 (the 2.1-branch documentation and the 2.1 release installed from PyPI) on
Python 3.13, checked on 2026-10-07. Every Python example was executed with those versions and exercised through
webtest.TestApp; the console output shown for proutes comes from a real run. The running example is the
Bookshelf Shelves scenario: public shelves owned by users, each holding books, with a small JSON API next to the
HTML pages.
URL dispatch is the first of the two ways Pyramid finds the code that answers a request. You declare an ordered list
of named routes, each with a pattern such as /shelves/{owner}; the router tests the request path against them one
by one and, at the first route that matches, stops, stores the extracted values on the request and hands over to view
lookup. If no route matches, Pyramid falls back to traversal, the second way, which walks a tree of objects instead
of a list of patterns (see the traversal and resources page of this section). Most
applications, including everything the cookiecutter starter generates, use URL dispatch only, and it is the model
that will feel familiar to anyone coming from Flask, Django or Tornado. The comparison table at the end of this page
lines them up.
How URL Dispatch Works
A route has a name (the identifier used to generate URLs and to attach views to it), a pattern matched against the
PATH_INFO of the request, optional route predicates that must all be true for the route to apply, and optionally a
factory that creates the context for the request. Routes are kept in an ordered route map, in the order in which
they were added. For every request the router does the following.
The figure shows two things that surprise newcomers. A route does not point at a view: a matched route only tells view
lookup which route_name to look for (the section "How a Route Reaches a View" below explains the mechanism). And a
failed predicate is not an error: the router silently moves on to the next route.
Source: URL Dispatch
Declaring a Route and Attaching a View
Configurator.add_route(name, pattern, ...) adds one route. Views attach to a route through the route_name
argument of add_view (or of @view_config). Both halves are needed: a route without a view matches and then
produces a 404, because view lookup finds nothing for it.
from pyramid.config import Configurator
from pyramid.response import Response
from webtest import TestApp
def client(config):
"""Commit the configuration and wrap the application for testing."""
return TestApp(config.make_wsgi_app())
def show_shelf(request):
owner = request.matchdict["owner"]
return Response(f"shelf of {owner} via route {request.matched_route.name}")
with Configurator() as config:
config.add_route("shelf", "/shelves/{owner}")
config.add_view(show_shelf, route_name="shelf")
app = client(config)
print(app.get("/shelves/ada").text)
print(app.get("/shelves/ada/extra", status=404).status)
The first request prints shelf of ada via route shelf; the second path has one segment more than the pattern, so
nothing matches and the application answers 404 Not Found. When the views live in a package, the usual style is to
keep add_route calls in one place and decorate the views with @view_config(route_name="shelf"), then call
config.scan(); the two styles are exactly equivalent.
# norun
from pyramid.view import view_config
@view_config(route_name="shelf", renderer="json")
def show_shelf(request):
return {"owner": request.matchdict["owner"]}
# in main(): config.add_route("shelf", "/shelves/{owner}"); config.scan()
Route names must be unique within one configuration. Adding two routes with the same name is detected when the
configuration is committed and raises ConfigurationConflictError, which is why larger applications use dotted names
such as shelves.show (see the section on route prefixes below).
Route Pattern Syntax
A pattern is a sequence of segments separated by slashes. Each segment is a literal, a replacement marker, or a mix
of both. The leading slash is optional: a pattern that does not start with a slash gets an implicit one, so
{owner}/books and /{owner}/books are the same pattern. To match the site root, use / or the empty string.
| Syntax | Meaning | Example |
|---|---|---|
|
Any run of characters up to the next slash, at least one character. Under the hood it is |
|
|
The marker matches only what the regular expression matches. Because the regex follows the colon directly, a pattern needing a colon or braces inside the regex must be written carefully. |
|
Marker with literals |
Literal text may surround a marker in the same segment. Matching of a marker stops at the first character that the pattern says must follow. |
|
|
A remainder: everything to the end of the path, as a tuple of URL-decoded segments. It must be the last thing in the pattern, and it does not need a slash before it. |
|
|
The same idea written as a regex: the remainder as one plain string, slashes included. |
|
Leading marker |
A pattern may start with a marker; the implicit slash is added in front. |
|
Segment values are URL-unquoted and decoded from UTF-8 before they reach the matchdict, so /shelves/La%20Pe%C3%B1a
matches /shelves/{owner} with 'La Peña'. The converse applies to literals: write the decoded text in the
pattern (/La Peña/{city} as a Python str), never a percent-encoded or byte string.
def echo(request):
return Response(repr(dict(request.matchdict)))
def try_pattern(pattern, *paths):
with Configurator() as config:
config.add_route("r", pattern)
config.add_view(echo, route_name="r")
app = client(config)
for path in paths:
resp = app.get(path, expect_errors=True)
print(f"{pattern:32} {path:22} {resp.status_int} {resp.text if resp.status_int == 200 else ''}")
try_pattern("/shelves/{owner}", "/shelves/ada", "/shelves/La%20Pe%C3%B1a", "/shelves/")
try_pattern(r"/api/books/{id:\d+}", "/api/books/12", "/api/books/dune")
try_pattern("/files/{name}.{ext}", "/files/dune.pdf", "/files/dune")
try_pattern("/files/*subpath", "/files/", "/files/a/b%20c")
try_pattern("/files/{rest:.*}", "/files/a/b/c")
try_pattern("{lang}/about", "/en/about")
try_pattern("", "/")
Running it prints one line per request, for example /shelves/ada gives 200 {'owner': 'ada'}, /shelves/ gives
404 (a marker needs at least one character), /api/books/dune gives 404 (the regex fails, so the route does not
match at all), /files/ gives {'subpath': ()} and /files/a/b%20c gives {'subpath': ('a', 'b c')}.
Source: Route Pattern Syntax
Two details of the grammar deserve care. Two adjacent markers with no literal between them, as in
/{year}{month}, are meaningless unless each carries a restricting regex, because the first marker would swallow
the whole segment. And there is no built-in typing: {id} always yields a string. Use {id:\d+} to refuse
non-numeric input at the routing level and convert with int() in the view, or convert in a custom route predicate
(see below). Pyramid has nothing like Flask’s <int:id> converters or FastAPI’s annotated path parameters; the
trade-off is that one small pattern language and plain regexes cover every route.
Reading the Match: matchdict and matched_route
When a route matches, the router attaches two attributes to the request.
-
request.matchdictis a dictionary of the values captured by the pattern. Keys are the marker names, values arestr(atupleofstrfor a*remainder). -
request.matched_routeis the matched route object. Its useful attributes arenameandpattern.
When no route matches, both are None. That matters in exactly the places that run for every request or for failed
ones: a tween, an event subscriber, a response callback and the not found view. Code in those places must not index
request.matchdict without checking it.
def not_found(request):
matched = request.matched_route.name if request.matched_route else None
return Response(f"matchdict={request.matchdict!r} matched_route={matched!r}", status=404)
with Configurator() as config:
config.add_route("shelf", "/shelves/{owner}")
config.add_view(show_shelf, route_name="shelf")
config.add_notfound_view(not_found)
app = client(config)
print(app.get("/nothing-here", status=404).text)
print(app.get("/shelves/ada").text)
The first line prints matchdict=None matched_route=None; the second request matches, and the view reads the owner
from request.matchdict. Keep the values in the view as plain strings and validate them before using
them in a path, a query or a filename; see the pitfalls section.
Source: The Matchdict and The Matched Route
Route Order: First Match Wins
Routes are evaluated in the order in which they were added, and the first one whose pattern and predicates match is
used. There is no specificity ranking: a general route placed early shadows every more specific route after it, and
Pyramid does not warn about it. This is the same discipline as Django’s urlpatterns and unlike Flask and
Werkzeug, which sort rules by complexity, so that order rarely matters there.
def view_a(request):
return Response("route " + request.matched_route.name)
def build(*routes):
with Configurator() as config:
for name, pattern in routes:
config.add_route(name, pattern)
config.add_view(view_a, route_name=name)
return client(config)
wrong = build(("by_owner", "/shelves/{owner}"), ("mine", "/shelves/mine"))
print(wrong.get("/shelves/mine").text)
right = build(("mine", "/shelves/mine"), ("by_owner", "/shelves/{owner}"))
print(right.get("/shelves/mine").text)
The first application prints route by_owner, so mine can never be reached; the second prints route mine. A
related trap appears with a *remainder or a {name:.*} pattern: put those routes last, because they match
everything below their prefix.
When the routes come from several included packages, the order is the order of the include calls, and inside one
package the order of its add_route statements. The proutes command (below) prints the routes in exactly the order
in which they are evaluated, which is the quickest way to check it.
Source: Route Declaration Ordering
Route Predicates
A pattern decides which path matches. A route predicate narrows the match with other facts about the request. All
the predicates of a route must be true for the route to match; if one is false, the router does not fail, it just
continues with the next route. The predicate arguments are passed to add_route.
| Argument | Route matches only when | Notes |
|---|---|---|
|
The HTTP method is the given string or one in the given tuple, such as |
|
|
The query string or form has the key; |
Reads |
|
The header is present ( |
Header names are case-insensitive, values are not. |
|
The |
Media ranges such as |
|
|
Rarely needed, the pattern does the job. |
|
The request has an |
Unreliable with modern |
|
|
New in 2.0. |
|
Every named principal is in the request’s effective principals. |
Deprecated in 2.0, principals left the request. |
|
Each callable |
Deprecated since 1.5. Use |
|
The custom predicates registered with |
See the next section. |
Two further arguments are not predicates: factory and traverse (both below) and pregenerator (URL generation,
below). use_global_views is a legacy flag for views registered without a route.
Because a mismatching predicate means "try the next route", the same pattern can be declared several times with
different predicates. That is the route-level way to separate verbs. The other way, usually preferred, is one route
and several views told apart by view predicates such as request_method on add_view (the views and view
configuration page of this section). Either way, Pyramid never answers 405 Method Not Allowed on
its own: when no route (or no view for the matched route) accepts the verb, the result is 404, and sending 405
with an Allow header is up to a custom not found view or a view registered with request_method that raises
HTTPMethodNotAllowed.
def list_books(request):
return Response("list")
def create_book(request):
return Response("create", status=201)
def json_only(request):
return Response("json clients only")
with Configurator() as config:
config.add_route("books_list", "/books", request_method="GET")
config.add_route("books_create", "/books", request_method="POST")
config.add_route("report_json", "/report", accept="application/json")
config.add_route("report_html", "/report")
config.add_route("export", "/export", request_param="format=csv", header="X-Api-Client")
config.add_view(list_books, route_name="books_list")
config.add_view(create_book, route_name="books_create")
config.add_view(json_only, route_name="report_json")
config.add_view(lambda request: Response("html page"), route_name="report_html")
config.add_view(lambda request: Response("csv"), route_name="export")
app = client(config)
print(app.get("/books").text, app.post("/books").status_int, app.head("/books").status_int)
print(app.put("/books", status=404).status)
print(app.get("/report", headers={"Accept": "application/json"}).text)
print(app.get("/report", headers={"Accept": "text/html"}).text)
print(app.get("/export?format=csv", headers={"X-Api-Client": "cli"}).text)
print(app.get("/export?format=csv", status=404).status)
The output is list 201 200 (note that HEAD is served by the GET route), then 404 Not Found for PUT, then the
JSON and HTML variants of /report (the catch-all report_html is declared after the narrower report_json, which is
how "everything except JSON" is expressed with routes), then csv, and finally 404 Not Found for the export request
that lacks the header.
The not_() helper, which negates a predicate for views (@view_config(request_method=not_("POST"))), is not usable
with add_route in Pyramid 2.1 even though the API page says accept supports it: the calls
add_route(..., request_method=not_("POST")) and add_route(..., accept=not_("application/json")) both fail while the
configuration is processed with a TypeError. Express negation with route order, as above, or with a view predicate.
Source: Configurator.add_route
Custom Route Predicates
When the built-in predicates are not enough, register a predicate factory with
Configurator.add_route_predicate(name, factory); name then becomes a new keyword argument of add_route (added in
Pyramid 1.4). The factory is called with the value given in add_route and an info object, and returns a predicate:
an object with a text() method (a short caption used by proutes, pviews and the debug toolbar), a phash()
method (a string identifying the predicate for configuration conflict detection; reusing text works) and a
__call__(info, request) that returns True or False. The info dictionary passed at match time holds match
(the matchdict under construction) and route (with name and pattern).
A predicate may also change info['match'], which is how a route can convert path values to other types before the
view sees them. Register such a conversion predicate last, return True unconditionally, and guard the conversion
with a regex in the pattern.
class AnyOf:
"""Route matches only if the named segment is one of the allowed values."""
def __init__(self, val, info):
self.segment, *self.allowed = val
def text(self):
return f"any_of = {self.segment}, {tuple(self.allowed)}"
phash = text
def __call__(self, info, request):
return info["match"][self.segment] in self.allowed
class Integers:
"""Convert the named matchdict values to int (the pattern guarantees digits)."""
def __init__(self, val, info):
self.names = val
def text(self):
return f"integers = {self.names}"
phash = text
def __call__(self, info, request):
match = info["match"]
for name in self.names:
match[name] = int(match[name])
return True
def format_view(request):
return Response(repr(dict(request.matchdict)))
with Configurator() as config:
config.add_route_predicate("any_of", AnyOf)
config.add_route_predicate("integers", Integers)
config.add_route("export_fmt", "/export/{fmt}", any_of=("fmt", "csv", "json"))
config.add_route("added", r"/added/{year:\d+}/{month:\d+}", integers=("year", "month"))
config.add_view(format_view, route_name="export_fmt")
config.add_view(format_view, route_name="added")
app = client(config)
print(app.get("/export/csv").text)
print(app.get("/export/xml", status=404).status)
print(app.get("/added/2026/10").text)
The three lines print {'fmt': 'csv'}, 404 Not Found (the pattern matched but the predicate refused) and
{'year': 2026, 'month': 10}. Pyramid orders predicates by an internal weight, not by the order of the keyword arguments, so do not build a
conversion pipeline out of several predicates that depend on one another’s side effects; do the whole conversion in
one predicate. The same predicate mechanism, with the same factory contract, serves views and subscribers
(add_view_predicate, add_subscriber_predicate).
Source: Custom Route Predicates
Generating URLs
A route’s name is its stable identifier, so URLs are generated from the name and the marker values instead of being
written out. If a pattern changes from /shelves/{owner} to /s/{owner}, every generated link follows.
-
request.route_url(name, *elements, **kw)builds a fully qualified URL (scheme, host and application URL included). -
request.route_path(name, *elements, **kw)builds only the path, which is what you usually want in HTML and JSON because it keeps working behind a proxy that changes the public host.
Marker values are passed as keyword arguments, and positional elements are appended as extra path segments. Values
are URL-quoted (Unicode is encoded as UTF-8 first), and a value for a *remainder may be a tuple of segments or a
string, whose embedded slashes are kept. A missing marker value raises KeyError.
| Keyword | Effect |
|---|---|
|
A mapping or a sequence of pairs for the query string, or a plain string (allowed since 1.5), which is URL-quoted and appended without needing the |
|
Fragment appended after |
|
Override the corresponding part of the URL. With |
|
Replace the application URL prefix wholesale; when present, |
def show_urls(request):
return Response("\n".join([
request.route_path("shelf", owner="La Peña"),
request.route_path("book", owner="ada", slug="dune", _query={"page": 2, "tag": ["sf", "classic"]}, _anchor="reviews"),
request.route_path("files", subpath=("covers", "dune 1.png")),
request.route_path("files", subpath="covers/dune.png"),
request.route_url("shelf", owner="ada"),
request.route_url("shelf", owner="ada", _scheme="https", _host="books.example.com", _port=443),
request.route_url("shelf", "settings", owner="ada", _app_url="https://cdn.example.com/app"),
]))
with Configurator() as config:
config.add_route("shelf", "/shelves/{owner}")
config.add_route("book", "/shelves/{owner}/{slug}")
config.add_route("files", "/files/*subpath")
config.add_route("urls", "/urls")
config.add_view(show_urls, route_name="urls")
app = client(config)
print(app.get("/urls").text)
The output is, line by line: /shelves/La%20Pe%C3%B1a; /shelves/ada/dune?page=2&tag=sf&tag=classic#reviews;
/files/covers/dune%201.png (tuple elements quoted and joined); /files/covers/dune.png (slashes in a string kept);
http://localhost/shelves/ada; https://books.example.com/shelves/ada; and
https://cdn.example.com/app/shelves/ada/settings.
Source: Request.route_url and Generating Route URLs
Static and External Routes
A route added with static=True is never matched against requests; it exists only so that route_url and
route_path can build URLs for it. That is how you generate links to a CDN, to a path owned by another application on
the same host, or to an asset that a web server serves directly. A route whose pattern is itself a valid URL is an
external route and is implicitly static (since 1.5). Asking route_path for an external route raises an error,
because it has no path of its own, and passing _app_url to route_url for one does as well. The only non-name,
non-pattern argument that still has effect on a static route is pregenerator.
def links(request):
return Response("\n".join([
request.route_url("youtube", video_id="oHg5SJYRHA0"),
request.route_path("covers_cdn", name="dune.png"),
request.route_url("covers_cdn", name="dune.png"),
]))
with Configurator() as config:
config.add_route("youtube", "https://www.youtube.com/watch/{video_id}")
config.add_route("covers_cdn", "/covers/{name}", static=True)
config.add_route("links", "/links")
config.add_view(links, route_name="links")
app = client(config)
print(app.get("/links").text)
print(app.get("/covers/dune.png", status=404).status)
try:
with Configurator() as config:
config.add_route("youtube", "https://www.youtube.com/watch/{video_id}")
config.add_route("probe", "/probe")
config.add_view(lambda request: Response(request.route_path("youtube", video_id="x")), route_name="probe")
client(config).get("/probe")
except Exception as exc:
print(type(exc).__name__)
The first block prints the generated YouTube URL, then /covers/dune.png, then http://localhost/covers/dune.png.
The static route is not matched by a request (404), and route_path on the external route raises ValueError, which
the last print shows. Pyramid’s own add_static_view and static_url for files in a directory are a
different mechanism built on the same ideas; they belong to the static assets page of this
section.
Source: Static Routes and External Routes
Pregenerators
A pregenerator is a callable pregenerator(request, elements, kw) passed to add_route that returns a new
(elements, kw) pair just before the URL is built. It lets one place add values that every URL of the route needs, such
as a default query parameter or a version, without touching each call site.
def with_lang(request, elements, kw):
kw.setdefault("_query", {})["lang"] = request.params.get("lang", "en")
return elements, kw
def where(request):
return Response(request.route_path("shelf", owner="ada"))
with Configurator() as config:
config.add_route("shelf", "/shelves/{owner}", pregenerator=with_lang)
config.add_route("where", "/where")
config.add_view(where, route_name="where")
app = client(config)
print(app.get("/where").text)
print(app.get("/where?lang=es").text)
It prints /shelves/ada?lang=en and then /shelves/ada?lang=es.
Source: Configurator.add_route
Trailing Slashes
Pyramid treats /shelves and /shelves/ as different paths: a pattern without a trailing slash does not match a
request that has one, and the other way round. There is no automatic redirect, unlike Django’s APPEND_SLASH and
Flask’s strict_slashes redirect behaviour. To get a redirect for URLs that lack a required slash, register a not
found view with append_slash=True, either with config.add_notfound_view(view, append_slash=True) or with
@notfound_view_config(append_slash=True). When no route matches and the path plus a slash matches a route, the
client is redirected there (302 by default). Passing a response class, for example
append_slash=HTTPMovedPermanently, uses that class instead. If the slashed path matches nothing either, the not
found view is called normally.
from pyramid.httpexceptions import HTTPMovedPermanently, HTTPNotFound
def notfound(request):
return HTTPNotFound()
def plain(request):
return Response("no slash")
def slashed(request):
return Response("has slash")
with Configurator() as config:
config.add_route("noslash", "/no_slash")
config.add_route("hasslash", "/has_slash/")
config.add_view(plain, route_name="noslash")
config.add_view(slashed, route_name="hasslash")
config.add_notfound_view(notfound, append_slash=HTTPMovedPermanently)
app = client(config)
print(app.get("/no_slash").text)
print(app.get("/no_slash/", status=404).status)
resp = app.get("/has_slash", status=301)
print(resp.status_int, resp.headers["Location"])
print(app.post("/has_slash", status=301).status)
The four lines print no slash, 404 Not Found (the slash-appended path /no_slash/ has no route),
301 http://localhost/has_slash/, and 301 Moved Permanently for the POST. That last line illustrates the pitfall
documented by Pyramid: a client that follows the redirect turns the POST into a GET and loses the body, so do not
rely on this mechanism for non-GET requests; design the API with one canonical form and make clients use it.
Composing Applications with route_prefix
Configurator.include(callable, route_prefix="/prefix") runs callable and prepends the prefix to every
add_route pattern made inside it, including routes of callables that one includes in turn. Route names are not
changed, which is what makes a package mountable under different prefixes by different applications. For code that
does not go through include, config.route_prefix_context("/prefix") is a context manager that sets the prefix for
the add_route and include calls made inside it. The constructor argument Configurator(route_prefix=...) applies
one to the whole configurator.
Prefixing has one special case. With a prefix of /users, a route with pattern '' normally becomes /users/.
Passing inherit_slash=True to add_route (Pyramid 2.0 and later; allowed only when the pattern is the empty
string) makes it /users instead, so the same code produces /users under the prefix /users and /users/ under
the prefix /users/, and the integrator and the author agree on whether a trailing slash is wanted. Prefixes compose
recursively, and they do not make route names unique: with many included packages, use dotted route names
(shelves.show, api.books.list) to avoid collisions.
def shelves_routes(config):
config.add_route("shelves.index", "", inherit_slash=True)
config.add_route("shelves.show", "/{owner}")
config.include(book_routes, route_prefix="/{owner}/books")
def book_routes(config):
config.add_route("books.show", "/{slug}")
def api_routes(config):
config.add_route("api.ping", "/ping")
def where_am_i(request):
return Response(request.route_path(request.matched_route.name, **request.matchdict) + " <- " + request.matched_route.pattern)
with Configurator() as config:
config.include(shelves_routes, route_prefix="/shelves")
config.include(api_routes, route_prefix="/api/v1")
with config.route_prefix_context("/admin"):
config.add_route("admin.home", "/")
for name in ("shelves.index", "shelves.show", "books.show", "api.ping", "admin.home"):
config.add_view(where_am_i, route_name=name)
app = client(config)
for path in ("/shelves", "/shelves/ada", "/shelves/ada/books/dune", "/api/v1/ping", "/admin/"):
print(path, "->", app.get(path).text)
The patterns that result are /shelves, /shelves/{owner}, /shelves/{owner}/books/{slug}, /api/v1/ping and
/admin/, and each printed line shows the request path, the generated path (identical) and the effective pattern
(Pyramid stores patterns without the implicit leading slash, so shelves/{owner} appears in the output).
A prefix may itself contain markers, as the /{owner}/books prefix shows, but then every route below it needs those
values whenever you generate a URL.
Route Factories and ACLs
Every request needs a context, the resource that security checks and views operate on. Without anything else,
that is the object created by the root factory of the Configurator (a plain object unless you set
root_factory). A route can override it with the factory argument: a callable that takes the request and returns
any object (a class whose __init__ accepts the request works), or a dotted name for one. Because the route has
matched before the factory runs, request.matchdict is already available to it, so a factory can load a database row
or build a small object from the matched values. The result is request.context in the view and the object the
security policy checks the permission against.
The built-in ACL authorization consults an __acl__ attribute on the context (and, if the context has
__parent__ links, on its parents). The route factory is the place to attach one with URL dispatch: it makes
"context sensitive" security declarative. In the Bookshelf Shelves scenario a shelf can be read by everyone and
edited only by its owner.
from pyramid.authorization import ACLHelper, Allow, Authenticated, DENY_ALL, Everyone
class ShelfContext:
def __init__(self, request):
self.owner = request.matchdict["owner"]
self.__acl__ = [
(Allow, Everyone, "view"),
(Allow, f"user:{self.owner}", "edit"),
DENY_ALL,
]
class DemoPolicy:
"""A deliberately naive policy for the demo: trusts an X-Demo-User header. Never do this in production."""
def __init__(self):
self.helper = ACLHelper()
def identity(self, request):
return request.headers.get("X-Demo-User")
def authenticated_userid(self, request):
return self.identity(request)
def permits(self, request, context, permission):
principals = [Everyone]
userid = self.identity(request)
if userid:
principals += [Authenticated, f"user:{userid}"]
return self.helper.permits(context, principals, permission)
def remember(self, request, userid, **kw):
return []
def forget(self, request, **kw):
return []
def read_shelf(request):
return Response(f"shelf of {request.context.owner}")
def edit_shelf(request):
return Response(f"editing shelf of {request.context.owner}")
with Configurator() as config:
config.set_security_policy(DemoPolicy())
config.add_route("shelf", "/shelves/{owner}", factory=ShelfContext)
config.add_route("shelf_edit", "/shelves/{owner}/edit", factory=ShelfContext)
config.add_view(read_shelf, route_name="shelf", permission="view")
config.add_view(edit_shelf, route_name="shelf_edit", permission="edit")
app = client(config)
print(app.get("/shelves/ada").text)
print(app.get("/shelves/ada/edit", status=403).status)
print(app.get("/shelves/ada/edit", headers={"X-Demo-User": "grace"}, status=403).status)
print(app.get("/shelves/ada/edit", headers={"X-Demo-User": "ada"}).text)
It prints shelf of ada, 403 Forbidden for an anonymous visitor, 403 Forbidden for another user and finally
editing shelf of ada for the owner. The DemoPolicy is only scaffolding; a real policy, with signed cookies and
proper identity handling, is the subject of the security policy and authorization pages of this section.
The point here is the wiring: route, factory, permission on the view, an ACL on the context.
Hybrid Applications: traverse on a Route
A route can hand part of the URL over to traversal. If the pattern ends in a *traverse remainder (or the route has a
traverse argument such as traverse="/{owner}"), the resource tree reached from the route’s factory is walked
using that path, and the result becomes the context. The matched route still decides which views are candidates
(through route_name), while the context found by traversal decides which of them wins. This "hybrid" style is
useful when most of an application is URL dispatch but one corner, such as a tree of shelves containing books, is
naturally hierarchical. The mechanics of resource trees, __getitem__ and __parent__ are explained in the
traversal and resources page of this section.
class Book:
def __init__(self, title):
self.title = title
class Shelf(dict):
pass
TREE = Shelf(dune=Book("Dune"), emma=Book("Emma"))
def tree_root(request):
return TREE
def book_view(request):
return Response("book: " + request.context.title)
def shelf_view(request):
return Response("books: " + ", ".join(sorted(request.context)))
with Configurator() as config:
config.add_route("tree", "/tree/*traverse", factory=tree_root)
config.add_view(book_view, route_name="tree", context=Book)
config.add_view(shelf_view, route_name="tree", context=Shelf)
app = client(config)
print(app.get("/tree/").text)
print(app.get("/tree/dune").text)
print(app.get("/tree/nope", status=404).status)
It prints books: dune, emma, then book: Dune, then 404 Not Found, because traversal could not find nope in the
tree and no view is registered for the context it stopped at with that view name. The traverse argument of add_route computes the traversal path from the matched values instead; see the official
hybrid chapter for the complete rules.
How a Route Reaches a View
Technically, URL dispatch does not map a pattern to a view callable. It is a way of locating a resource: a route
determines the context (through its factory) and an interface marking the request as "matched route X", and then
the ordinary view lookup runs. At startup, add_route creates a route-specific request interface, and every
add_view(route_name="X") registers a view for that interface. At request time, the matched request is marked with the
interface of its route, so view lookup finds exactly the views of that route and ignores views registered for other
routes. This has three practical consequences.
-
A view without
route_nameis not tied to any route. It can still be found, but only for requests where no route matched (traversal) or through the route’suse_global_views=True. -
Several views can be registered for one route and told apart by view predicates (
request_method,accept,context,request_param,permissionand so on), which is the preferred way to build a REST resource out of a single route. -
The same
route_nameaccepts views added in other modules, other packages and add-ons, so an add-on can contribute a view to a route that the application defines.
Debugging Route Matching
Three tools answer "why did this request not match?".
proutes prints every route of an application in evaluation order: name, pattern, the view that would answer it
and the request method. Its argument is a config URI, proutes development.ini (an optional #name selects another
section; main is the default). -g GLOB or --glob GLOB shows only routes whose name or pattern matches the glob,
and -f or --format chooses the columns from name, pattern, view and method. The same columns can be set
permanently in a [proutes] section of the .ini file (format = view name pattern). The method column shows
<route mismatch> when the view found for the route does not accept any of the route’s request methods, and the view
column shows <unknown> when no view could be found.
proutes development.ini
proutes development.ini -g '/shelves*'
proutes development.ini --format pattern,view
For a small module that declares the five routes of the scenario (home, shelves, shelf, book and api_book),
with views added through @view_config and config.scan(), the first command prints the table below. The view column
shows the dotted name of each view callable, the method column * for a view that accepts any method and a
comma-separated list otherwise, and the -g and --format variants narrow the rows and the columns.
Name Pattern View Method
---- ------- ---- ------
home / shelvesapp.home *
shelves /shelves shelvesapp.list_shelves GET
shelf /shelves/{owner} shelvesapp.show_shelf GET
book /shelves/{owner}/{slug} shelvesapp.show_book GET
api_book /api/books/{id:\d+} shelvesapp.api_book GET,PUT
debug_routematch is a setting (pyramid.debug_routematch = true in the .ini file, or the environment variable
PYRAMID_DEBUG_ROUTEMATCH=true; pyramid.debug_all turns it on with the other debug flags). For every request
Pyramid then logs, at debug level, the matched route’s name, the PATH_INFO, the pattern, the matchdict and the
text of the predicates, or the words no route matched for url. The records go to the debug logger, which by default
is the logger named after the package that created the Configurator, so the logging configuration must let that
logger emit DEBUG. In a development .ini file that is the existing [logger_<package>] section with
level = DEBUG.
import logging
class Capture(logging.Handler):
def __init__(self):
super().__init__(level=logging.DEBUG)
self.messages = []
def emit(self, record):
self.messages.append(record.getMessage())
capture = Capture()
logger = logging.getLogger("shelves")
logger.addHandler(capture)
logger.setLevel(logging.DEBUG)
with Configurator(settings={"pyramid.debug_routematch": "true"}, debug_logger="shelves") as config:
config.add_route("shelf", "/shelves/{owner}", request_method="GET")
config.add_view(show_shelf, route_name="shelf")
app = client(config)
app.get("/shelves/ada")
app.get("/nope", status=404)
app.post("/shelves/ada", status=404)
for message in capture.messages:
print(message)
Each of the three requests produces one line. The first reads
route matched for url http://localhost/shelves/ada; route_name: 'shelf', path_info: '/shelves/ada', pattern: '/shelves/{owner}', matchdict: {'owner': 'ada'}, predicates: 'request_method = GET,HEAD',
the others no route matched for url http://localhost/nope and no route matched for url http://localhost/shelves/ada
(the POST was refused by the request_method predicate, which is how a "wrong verb" shows up in this log). The
request_method = GET,HEAD text also shows that a GET route accepts HEAD.
pviews takes a config URI and a URL path, pviews development.ini /shelves/ada, and prints the route that matches
that path and the views that view lookup would consider, with their predicates. It answers the second half of the
question: the route matched, but why did the wrong view run, or none? It is described with the other p-scripts in the
command line page of this section.
Pitfalls and Security
-
Shadowed routes. A broad pattern before a narrow one hides the narrow one, silently. Order routes from the most specific to the most general, put
*remainderroutes last and check withproutes. -
Everything is a string.
{id}yieldsstr. Restrict it with a regex ({id:\d+}) and convert explicitly; anint()on unchecked input raisesValueErrorand a 500. -
Regex backtracking. A marker’s regex runs on attacker-controlled paths. Keep it simple and anchored by the surrounding literals; avoid nested quantifiers such as
(a+)+. -
Path values are untrusted. A
*remainderor{name:.*}can contain..segments after decoding. Never join them onto a filesystem path; use a static view (which refuses to leave its directory) or validate against an allow-list. -
Authorization lives on the context, not the pattern. Route patterns are not a security boundary. Put
permissionon views (ordefault_permissionon the configurator) and attach ACLs through the route factory, so a new route without a permission does not become an open door. -
404, not 405. A wrong verb produces
404unless you write the405yourself, which confuses API clients. -
Trailing slash.
/booksand/books/are different; pick one and redirect withappend_slash, never for POST. -
Route name clashes. Names are global to the configuration. Use dotted names in reusable packages.
-
Predicates that mutate the match. A predicate that converts
matchdictvalues changes what later predicates and the view see. Keep it the last predicate of its route and do all conversions in one place. -
Open redirects. If a view redirects to a path taken from the request (
next), allow only relative paths of your own routes, ideally throughroute_path, and never a full URL supplied by the client. -
Generated URLs behind proxies.
route_urluses the host and scheme Pyramid sees. Behind a reverse proxy, configure the proxy headers (or a forwarded-headers middleware) or useroute_path; do not build absolute URLs fromrequest.hostby hand.
What Changed in Pyramid 2.0 and 2.1
URL dispatch is one of the most stable parts of Pyramid, and code written for 1.x routes works unchanged. The changes since 1.x are small:
| Version | Change |
|---|---|
1.1 |
|
1.2 |
|
1.4 |
|
1.5 |
External routes (patterns that are valid URLs); |
2.0 |
|
2.1 |
No change to the routing syntax or to |
Tutorials written for the 1.x era may still show custom_predicates=, effective_principals= or authentication-policy
based ACL wiring; use add_route_predicate, is_authenticated and a security policy instead.
Comparison with Other Frameworks
| Framework | How routes are declared | Differences from Pyramid |
|---|---|---|
Pyramid |
|
Routing and views are decoupled; traversal as an alternative; predicate-based dispatch; no automatic 405. |
Flask |
|
Typed converters; rules are sorted by specificity, not by declaration order; automatic 405 and |
Tornado |
A list of |
Regex capture groups and first match wins like Pyramid, but the handler class decides the verb. |
Django |
|
Ordered like Pyramid; typed path converters; |
FastAPI |
Decorators with typed path parameters, see Path Parameters. |
Validation by type annotations; ASGI; automatic OpenAPI documentation. |
For a first look at the whole framework, see the Getting Started page, and
for the Configurator calls used in the examples (include, route_prefix, conflict detection), the
Configuration & the Configurator page.
Summary
-
URL dispatch is an ordered list of named routes; the first route whose pattern and predicates match sets
request.matchdictandrequest.matched_route, and unmatched requests fall back to traversal. -
Patterns use
{name},{name:regex}and*remainder; everything captured is a string (a tuple for remainders), URL-decoded. -
Predicates narrow a route; a failed predicate means "try the next route", and Pyramid never answers 405 by itself.
-
route_urlandroute_pathbuild URLs from the route name, with_query,_anchorand_app_url; static and external routes exist only for URL generation. -
route_prefix,route_prefix_contextandinherit_slash=Truecompose applications from included packages. -
A route
factorybuilds thecontextand is the natural place for an__acl__. -
proutes,pviewsandpyramid.debug_routematchare the debugging tools.