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.

flowchart TD req["Request path and headers"] --> nxt["Next route in the route map"] nxt --> pat{"Pattern matches PATH_INFO?"} pat -->|"no"| more{"More routes?"} pat -->|"yes"| pred{"All route predicates true?"} pred -->|"no"| more pred -->|"yes"| hit["Set request.matchdict and request.matched_route"] more -->|"yes"| nxt more -->|"no"| trav["No route matched: use traversal from the root factory"] hit --> fac["Call the route factory (or the root factory) to build request.context"] fac --> vl["View lookup by route_name, context and predicates"] trav --> vl
Figure 1. How the router uses the route map: the first route whose pattern and predicates match wins, otherwise the request falls through to traversal.

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.

Table 1. Everything that can appear in a route pattern
Syntax Meaning Example

{name}

Any run of characters up to the next slash, at least one character. Under the hood it is {name:[^/]+}. The name must start with an ASCII letter or an underscore and contain only ASCII letters, digits and underscores (a leading underscore is allowed since Pyramid 1.2).

/shelves/{owner} matches /shelves/ada with {'owner': 'ada'}

{name:regex}

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.

/api/books/{id:\d+} matches /api/books/12 but not /api/books/dune

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.

/files/{name}.{ext} matches /files/dune.pdf with name='dune' and ext='pdf'

*name

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.

/files/*subpath matches /files/a/b/c with ('a', 'b', 'c') and /files/ with ()

{name:.*}

The same idea written as a regex: the remainder as one plain string, slashes included.

/files/{rest:.*} matches /files/a/b/c with 'a/b/c'

Leading marker

A pattern may start with a marker; the implicit slash is added in front.

{lang}/about is /{lang}/about

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')}.

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.matchdict is a dictionary of the values captured by the pattern. Keys are the marker names, values are str (a tuple of str for a *remainder).

  • request.matched_route is the matched route object. Its useful attributes are name and pattern.

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.

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.

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.

Table 2. Predicate arguments of add_route (Pyramid 2.1)
Argument Route matches only when Notes

request_method

The HTTP method is the given string or one in the given tuple, such as ("PUT", "PATCH").

"GET" also matches HEAD. Tuples since 1.2.

request_param

The query string or form has the key; "format=json" also requires that value; an iterable requires all of them.

Reads request.params, so a form field satisfies it too.

header

The header is present ("If-None-Match") or, with a colon, its value matches a regex ("Host:localhost").

Header names are case-insensitive, values are not.

accept

The Accept header allows the media type or one of a list of types, or there is no Accept header.

Media ranges such as text/* were removed in 2.0.

path_info

PATH_INFO matches a regular expression.

Rarely needed, the pattern does the job.

xhr

The request has an X-Requested-With header.

Unreliable with modern fetch, prefer header or accept.

is_authenticated

True requires a user authenticated by the security policy, False an anonymous request.

New in 2.0.

effective_principals

Every named principal is in the request’s effective principals.

Deprecated in 2.0, principals left the request.

custom_predicates

Each callable fn(info, request) returns true.

Deprecated since 1.5. Use add_route_predicate.

**predicates

The custom predicates registered with add_route_predicate.

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.

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).

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.

Table 3. Special keyword arguments of route_url and route_path
Keyword Effect

_query

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 k=v form. In a mapping, a list or tuple value is repeated and a None value is rendered as an empty value (p=).

_anchor

Fragment appended after #.

_scheme, _host, _port

Override the corresponding part of the URL. With _scheme and no _port, the default port of that scheme is used, so pass _port too when it differs. Only route_url.

_app_url

Replace the application URL prefix wholesale; when present, _scheme, _host and _port are ignored. Only route_url.

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.

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.

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.

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_name is not tied to any route. It can still be found, but only for requests where no route matched (traversal) or through the route’s use_global_views=True.

  • Several views can be registered for one route and told apart by view predicates (request_method, accept, context, request_param, permission and so on), which is the preferred way to build a REST resource out of a single route.

  • The same route_name accepts 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 *remainder routes last and check with proutes.

  • Everything is a string. {id} yields str. Restrict it with a regex ({id:\d+}) and convert explicitly; an int() on unchecked input raises ValueError and 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 *remainder or {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 permission on views (or default_permission on 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 404 unless you write the 405 yourself, which confuses API clients.

  • Trailing slash. /books and /books/ are different; pick one and redirect with append_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 matchdict values 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 through route_path, and never a full URL supplied by the client.

  • Generated URLs behind proxies. route_url uses the host and scheme Pyramid sees. Behind a reverse proxy, configure the proxy headers (or a forwarded-headers middleware) or use route_path; do not build absolute URLs from request.host by 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

static=True routes, for URL generation only.

1.2

route_prefix for include, tuples for request_method, route markers may start with an underscore.

1.4

add_route_predicate and keyword custom predicates.

1.5

External routes (patterns that are valid URLs); custom_predicates deprecated in favour of add_route_predicate; _query as a plain string.

2.0

inherit_slash=True for empty patterns under a prefix; the is_authenticated predicate; effective_principals route predicate deprecated; media ranges removed from the accept predicate; the new security policy interface replaces authentication and authorization policies, which affects how ACLs are consulted (see the security pages of this section).

2.1

No change to the routing syntax or to add_route. The p-scripts (pserve, proutes, pviews and others) now pass a __script__ option when they load the application, visible in the global_conf of the application factory.

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

add_route(name, pattern, **predicates) plus views attached by route_name; ordered; regex per marker.

Routing and views are decoupled; traversal as an alternative; predicate-based dispatch; no automatic 405.

Flask

@app.route("/books/<int:id>") decorators over Werkzeug rules, see Routing & URL Building.

Typed converters; rules are sorted by specificity, not by declaration order; automatic 405 and OPTIONS.

Tornado

A list of (regex, handler) rules, see Routing.

Regex capture groups and first match wins like Pyramid, but the handler class decides the verb.

Django

path() and re_path() in a URLconf, see URLs & Routing.

Ordered like Pyramid; typed path converters; APPEND_SLASH redirects automatically.

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.matchdict and request.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_url and route_path build URLs from the route name, with _query, _anchor and _app_url; static and external routes exist only for URL generation.

  • route_prefix, route_prefix_context and inherit_slash=True compose applications from included packages.

  • A route factory builds the context and is the natural place for an __acl__.

  • proutes, pviews and pyramid.debug_routematch are the debugging tools.