Advanced Topics

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 (released 2026-03-11), checked on 2026-10-07. Every Python example was executed on Python 3.13 with Pyramid 2.1, WebTest 3.0.7, WebOb 1.8.11, zope.interface 8.6 and zope.component 7.1 (the last one installed separately; it is not a dependency of Pyramid). The running example is the Bookshelf Shelves scenario.

The earlier pages of this section teach the parts of Pyramid that nearly every application uses. This page collects the features that are used rarely but that explain a lot when you meet them: invoking another view without an HTTP round trip, asking the framework what it has been configured to do, taking control of HTTP caching headers, caching a value for the lifetime of one request, the thread-local variables that sit behind get_current_request(), the component registry that Pyramid is built on, and the corners of combining traversal with URL dispatch that the traversal page only touches. Each topic stands alone, so read the one you need.

Topic Use it when Where it is introduced first

Subrequests

One view needs the response of another view of the same application

Request and Response

The introspector

A tool or an audit must list routes, views or permissions

Configuration and the Configurator

HTTP caching

Browsers and proxies should reuse (or never reuse) a response

Views and View Configuration

RequestLocalCache

An expensive value is needed from several places during one request

Security Policy and Authentication

Thread locals

Legacy code, tests or scripts need "the current" request or registry

Introduction and Architecture

The component registry

You want utilities, adapters or the ZCA global API

this page

Hybrid applications

A route must hand part of the URL to traversal

Traversal and Resources

Subrequests

A subrequest obtains a response from a view of the same application while another view is running. You build a new request, hand it to request.invoke_subrequest(), and get a Response back, whatever the target view returned: a view that uses a renderer or returns something a response adapter understands is turned into a real response, which is the main advantage over importing the view function and calling it. Subrequests are slower than a plain function call and less convenient when you only want the data, so reach for one only when you need the full pipeline of the target view (its renderer, its permission check, its predicates, its caching headers).

The method takes the request to run and one keyword argument, use_tweens, which defaults to False:

use_tweens What happens

False (default)

The request goes straight to the router’s main handler. Tweens are skipped, so an exception raised by the target view is raised to the caller of invoke_subrequest() even if an exception view is configured for it.

True

The request enters the tween chain at the ingress end, as a real request would. The exception-view tween converts exceptions into responses, and every other tween runs too (a pyramid_tm tween commits or aborts, for instance).

Either way the call also manages the thread-local stack so that get_current_request() inside the subrequest returns the subrequest; attaches registry and invoke_subrequest to the request; applies request extensions added with add_request_method; sends the NewRequest, ContextFound and NewResponse events; checks the permission of the target view for the user implied by the subrequest; and runs response and finished callbacks. Build the subrequest with Request.blank() instead of reusing the original request: reusing it is documented as a poor idea.

from pyramid.config import Configurator
from pyramid.request import Request
from webtest import TestApp

BOOKS = {"1": "Dune", "2": "Hyperion"}


def api_book(request):
    book_id = request.matchdict["id"]
    if book_id not in BOOKS:
        raise LookupError(f"no book {book_id}")
    return {"id": book_id, "title": BOOKS[book_id]}


def book_page(request):
    sub = Request.blank("/api/books/" + request.matchdict["id"])
    try:
        data = request.invoke_subrequest(sub).json
    except LookupError as exc:  # no tweens: the exception reaches us
        data = {"raised": str(exc)}
    return {"page": data}


def book_page_tweens(request):
    sub = Request.blank("/api/books/" + request.matchdict["id"])
    response = request.invoke_subrequest(sub, use_tweens=True)
    return {"status": response.status, "body": response.json}


def lookup_error_view(exc, request):
    request.response.status_int = 404
    return {"error": str(exc)}


with Configurator() as config:
    config.add_route("api_book", "/api/books/{id}")
    config.add_route("page", "/books/{id}")
    config.add_route("page_tweens", "/books/{id}/tweens")
    config.add_view(api_book, route_name="api_book", renderer="json")
    config.add_view(book_page, route_name="page", renderer="json")
    config.add_view(book_page_tweens, route_name="page_tweens", renderer="json")
    config.add_exception_view(lookup_error_view, context=LookupError, renderer="json")
    app = TestApp(config.make_wsgi_app())

print(app.get("/books/1").json)
print(app.get("/books/9").json)
print(app.get("/books/9/tweens").json)
print(app.get("/api/books/9", status=404).json)

The output is {'page': {'id': '1', 'title': 'Dune'}}, then {'page': {'raised': 'no book 9'}} for the first subrequest on an unknown book (the LookupError came out of invoke_subrequest()), then {'status': '404 Not Found', 'body': {'error': 'no book 9'}} once use_tweens=True let the exception view build a response, and finally the same error body when a client calls the API route directly. The renderer of the target view produced the JSON in every case, which is why .json works on the subrequest’s response.

flowchart TD A["View calls request.invoke_subrequest(sub, use_tweens=...)"] --> B{"use_tweens?"} B -->|"False (default)"| D["Router main handler: route match, traversal, view lookup"] B -->|"True"| C["Tween chain: pyramid_tm, exception-view tween, and so on"] C --> D D --> E["Target view: permission check, renderer, caching"] E --> F["Response returned to the calling view"] E -.->|"exception"| G{"Tween chain used?"} G -->|"yes"| H["Exception view makes a response"] G -->|"no"| I["Exception raised to the calling view"] H --> F
Figure 1. A subrequest with and without tweens

Carrying the Identity Into a Subrequest

A blank request has an empty environment. It does not inherit the caller’s cookies, headers or authenticated user, and the target view’s permission is checked against the subrequest. Copy whatever your security policy reads. This example uses a policy that trusts the WSGI REMOTE_USER variable, which a web server sets after its own authentication (never trust a client-supplied header for this):

from pyramid.security import Allowed, Denied


class RemoteUserPolicy:
    def identity(self, request):
        return request.environ.get("REMOTE_USER")

    def authenticated_userid(self, request):
        return self.identity(request)

    def permits(self, request, context, permission):
        if self.identity(request) == "ada":
            return Allowed("ada may do everything in this demo")
        return Denied("anonymous")


def shelf_data(request):
    return {"shelf": "ada", "user": request.environ.get("REMOTE_USER")}


def dashboard(request):
    sub = Request.blank("/api/shelf")
    user = request.environ.get("REMOTE_USER")
    if user:
        sub.environ["REMOTE_USER"] = user  # hand the identity over on purpose
    return request.invoke_subrequest(sub, use_tweens=True)


with Configurator() as config:
    config.set_security_policy(RemoteUserPolicy())
    config.add_route("shelf", "/api/shelf")
    config.add_route("dashboard", "/dashboard")
    config.add_view(shelf_data, route_name="shelf", renderer="json", permission="view")
    config.add_view(dashboard, route_name="dashboard")
    secured = config.make_wsgi_app()

print(TestApp(secured, extra_environ={"REMOTE_USER": "ada"}).get("/dashboard").json)
print(TestApp(secured).get("/dashboard", status=403).status)

The first call prints {'shelf': 'ada', 'user': 'ada'} and the anonymous one prints 403 Forbidden: the denial came from the subrequest and the calling view returned that response unchanged. Subrequests do not bypass security, and they must not be fed paths taken from user input, because they can reach routes that the page was never meant to expose.

Invoking an Exception View by Hand

The same chapter describes request.invoke_exception_view(exc_info=None, request=None, secure=True, reraise=False). Inside an except block it runs the exception view registered for the exception and returns its response, or None when no view matches. It is useful when you catch an exception yourself but still want the application’s standard error page; most code should simply let the exception-view tween do it. The errors page covers exception views in full.

The Introspector

The introspector, queried as request.registry.introspector, is the framework’s own record of its configuration; the configuration page introduces it with a route lookup. Its categories are described in the official chapter: routes, views, permissions, templates, tweens, renderer factories, static views, traversers and others, and add-on packages may add their own. The methods are get(category, discriminator), get_category(category), categories(), categorized() and related(). get_category returns a list of dictionaries with an introspectable and its related introspectables.

A practical use is an audit. The next function lists every route-bound view with its route pattern, its request method and its permission, so that a test can assert that no view was left public by accident:

def audit(registry):
    rows = []
    for entry in registry.introspector.get_category("views"):
        view = entry["introspectable"]
        if view["route_name"] is None:
            continue
        perms = [r.discriminator for r in entry["related"] if r.category_name == "permissions"]
        routes = [r["pattern"] for r in entry["related"] if r.category_name == "routes"]
        rows.append((routes[0], view["request_methods"], perms[0] if perms else None))
    return sorted(rows, key=str)


with Configurator() as config:
    config.add_route("books", "/books")
    config.add_route("book", "/books/{id}")
    config.add_route("health", "/health")
    config.add_view(lambda r: {}, route_name="books", request_method="GET", renderer="json")
    config.add_view(lambda r: {}, route_name="books", request_method="POST", permission="edit", renderer="json")
    config.add_view(lambda r: {}, route_name="book", permission="view", renderer="json")
    config.add_view(lambda r: {}, route_name="health", renderer="json")
    audited = config.make_wsgi_app()

for row in audit(audited.registry):
    print(row)
public = [row for row in audit(audited.registry) if row[2] is None]
assert [row[0] for row in public] == ["/books", "/health"]

It prints ('/books', 'GET', None), ('/books', 'POST', 'edit'), ('/books/{id}', None, 'view') and ('/health', None, None). The two rows without a permission are the public views, which is intended for GET /books and /health here. Note that a default permission set with set_default_permission is not related to each view in the introspector; to audit a deployment that uses one, also read the default permission category.

Every add_view introspectable carries the keys accept, attr, callable, containment, context, decorator, derived_callable, exception_only, header, http_cache, mapper, match_param, name, order, path_info, phash, predicates, request_methods, request_param, require_csrf, route_name and xhr in the 2.1 code I inspected; the official chapter documents a shorter list, so treat the extra keys as informational rather than as a stable API. Debug toolbars and reports use the same data.

Introspection records data for every configuration directive. A Configurator created with introspection=False throws every introspectable away; the registry then still has an introspector object but it is empty:

with Configurator(introspection=False) as config:
    config.add_route("books", "/books")
    quiet = config.make_wsgi_app()

print(quiet.registry.introspector.categories(), quiet.registry.introspector.get_category("views"))

It prints [] None: any code, including your own audit, that relies on the introspector finds nothing (and get_category returns None, so the audit function above would fail) when it is disabled. Keep it enabled unless memory or start-up time on a very large application is a measured problem. The debug toolbar renders the introspector as a "Global" tab, and it is a development tool; it is never installed in a production deployment (the logging and debugging page of this section covers it).

HTTP Caching and Pyramid

Pyramid does not cache anything on the server; it helps you write the headers that let browsers and shared caches do it. The view argument is http_cache, which accepts the forms in the next table. A non-tuple value is equivalent to calling response.cache_expires(value) in the view body; a tuple (seconds, options) is equivalent to response.cache_expires(seconds, **options), where the options are anything the WebOb CacheControl object accepts.

http_cache value Meaning Headers

A non-zero integer

Cache for that many seconds

Expires and Cache-Control: max-age=N

A datetime.timedelta

Same, in a duration object

Expires and Cache-Control: max-age=N

0

Never cache

Cache-Control: max-age=0, must-revalidate, no-cache, no-store and an Expires header

(seconds, {"public": True})

Seconds plus extra directives

max-age and public in Cache-Control, plus Expires

(None, {...})

Directives only, leave Expires alone

Cache-Control only

from datetime import timedelta
from pyramid.response import Response


def books_view(request):
    response = Response("books")
    if "private" in request.GET:
        response.cache_control.prevent_auto = True  # this answer must not be cached by http_cache
    return response


def build(settings=None):
    with Configurator(settings=settings) as config:
        values = {
            "seconds": 3600,
            "timedelta": timedelta(days=1),
            "never": 0,
            "public": (3600, {"public": True}),
            "cc_only": (None, {"public": True, "max_age": 60}),
        }
        for name, value in values.items():
            config.add_route(name, "/" + name)
            config.add_view(books_view, route_name=name, http_cache=value)
        return TestApp(config.make_wsgi_app())


client = build()
for path in ("/seconds", "/timedelta", "/never", "/public", "/cc_only", "/seconds?private=1"):
    res = client.get(path)
    print(path, res.headers.get("Cache-Control"), "|", "Expires" in res.headers)

The output is, one line per request:

/seconds max-age=3600 | True
/timedelta max-age=86400 | True
/never max-age=0, must-revalidate, no-cache, no-store | True
/public max-age=3600, public | True
/cc_only max-age=60, public | False
/seconds?private=1 None | False

The last line shows prevent_auto: the http_cache machinery leaves that response alone, so a single view can cache the normal answer and refuse to cache a special one. Without prevent_auto the http_cache wrapper overwrites cache headers that the view body set itself.

The pyramid.prevent_http_cache setting (environment variable PYRAMID_PREVENT_HTTP_CACHE, parsed like the other boolean settings of the settings page) disables the http_cache argument for the whole application, which is convenient in development and in staging where you do not want a browser to hold stale pages:

off = build({"pyramid.prevent_http_cache": "true"})
print(off.get("/seconds").headers.get("Cache-Control"))
assert off.get("/seconds").headers.get("Cache-Control") is None

The result is None. The setting affects only responses shaped by http_cache; headers that a view sets in its own body and the caching headers of static views and FileResponse are separate.

Decisions that matter more than the syntax:

  • Add public only to content that is identical for every user. A max-age alone lets shared caches (a CDN, a corporate proxy) store the response; anything that depends on a cookie or an Authorization header should use 0 or no-store, and Vary should name the request headers that change the body.

  • http_cache sets freshness (how long a copy may be reused). Validators (ETag, Last-Modified with conditional responses) let a client revalidate cheaply once a copy is stale; the request and response page shows them and the views page covers decorator and wrapper next to http_cache.

  • A server-side cache of rendered fragments is a different problem, solved with libraries such as dogpile.cache; the Flask page on caching compares the two layers in another framework.

RequestLocalCache

pyramid.request.RequestLocalCache (added in Pyramid 2.0) is a store that keeps a value for the life of one request. It wraps a function that takes the request: the first call computes the value, later calls during the same request return the stored object, and a new request starts empty. It keeps a weak reference to each request and removes the value through a finished callback as soon as the request ends, so nothing leaks between requests. It exists mainly for security policies, which are asked about the identity several times per request (see the policy examples on the security policy page), but any expensive per-request value fits: the current shelves of the user, a parsed token, a feature-flag set.

from pyramid.request import RequestLocalCache

calls = []


@RequestLocalCache()
def current_shelves(request):
    calls.append(request.path)  # stands for a database query
    return ["ada", "grace"]


def shelves_view(request):
    first = current_shelves(request)
    second = current_shelves(request)
    return {"same_object": first is second, "total_calls": len(calls)}


with Configurator() as config:
    config.add_route("shelves_cache", "/cache")
    config.add_view(shelves_view, route_name="shelves_cache", renderer="json")
    cache_app = TestApp(config.make_wsgi_app())

print(cache_app.get("/cache").json, cache_app.get("/cache").json)

Both calls in one request return the same object, so the function ran once per request: the output is {'same_object': True, 'total_calls': 1} {'same_object': True, 'total_calls': 2}. The decorated function carries the cache as its cache attribute, and the cache has four methods:

Method Behaviour

get_or_create(request, creator=None)

Return the stored value, computing it with the creator (the decorated function, the constructor argument, or the one passed here) when it is missing. Raises ValueError when there is no creator at all.

get(request, default=NO_VALUE)

Return the stored value or the default; never computes.

set(request, value)

Store a value, registering the cleanup callback only once per request.

clear(request)

Forget the value and return the previous one (or NO_VALUE); the next call recomputes.

cache = RequestLocalCache()
req = Request.blank("/x")
print(cache.get(req) is RequestLocalCache.NO_VALUE)
print(cache.get_or_create(req, lambda r: 42), cache.get(req))
print(cache.clear(req), cache.get(req) is cache.NO_VALUE)

It prints True, 42 42 and 42 True. To cache a method, bind the creator in the constructor so that the cache only needs the request: self.identity_cache = RequestLocalCache(self.load_identity) and return self.identity_cache.get_or_create(request), as the security page does.

How it relates to the alternatives: add_request_method(func, name, reify=True) also computes once per request but stores the result as an attribute of the request object, so it is reachable as request.name and cannot be cleared; RequestLocalCache lives outside the request, needs no registration, can be invalidated with clear (after a logout inside the same request, for instance), and can be used from code that is handed a request but must not modify it. A subrequest is a different request object and has its own entry.

Thread Locals

While a request is processed, Pyramid keeps two values in a thread-local stack: the registry and the request. The router pushes them when the request starts and pops them when it ends, and a subrequest pushes a second pair on top, so "current" always means the topmost entry. pyramid.threadlocal.get_current_request() and get_current_registry() read it. The official chapter is blunt about them: they are kept for historical reasons, Pyramid internals use them, and application code should almost never call them, because code that does cannot be tested or scripted without a request. Pass the request (or data derived from it) as an argument instead.

import threading
from pyramid.threadlocal import get_current_request, get_current_registry, manager

seen = {}


def probe(request):
    seen["same_request"] = get_current_request() is request
    seen["same_registry"] = get_current_registry() is request.registry
    seen["depth"] = len(manager.stack)
    worker = {}
    thread = threading.Thread(
        target=lambda: worker.update(request=get_current_request(), registry=get_current_registry())
    )
    thread.start()
    thread.join()
    seen["thread_request"] = worker["request"]
    seen["thread_sees_app_registry"] = worker["registry"] is request.registry
    seen["inner"] = request.invoke_subrequest(Request.blank("/inner")).json
    seen["after_sub"] = get_current_request() is request
    return {"ok": True}


def inner(request):
    return {"current_is_inner": get_current_request() is request, "depth": len(manager.stack)}


with Configurator() as config:
    config.add_route("probe", "/probe")
    config.add_route("inner", "/inner")
    config.add_view(probe, route_name="probe", renderer="json")
    config.add_view(inner, route_name="inner", renderer="json")
    wsgi = config.make_wsgi_app()

TestApp(wsgi).get("/probe")
print(seen)
print(get_current_request(), get_current_registry() is wsgi.registry)

The dictionary printed is {'same_request': True, 'same_registry': True, 'depth': 1, 'thread_request': None, 'thread_sees_app_registry': False, 'inner': {'current_is_inner': True, 'depth': 2}, 'after_sub': True}, followed by None False for code running outside any request. Three lessons follow:

  • The stack is per thread. A thread or a thread-pool task started from a view sees the default values (no request and the process-wide global registry, not your application’s registry), so pass the request, or the data you need, to background work explicitly.

  • A subrequest pushes its own entry (depth 2) and pops it afterwards, so the outer request is current again.

  • Outside a request, get_current_request() is None and get_current_registry() is a global registry that is not your application’s.

Where "current" is legitimately useful is code that has no request parameter by design and is rarely changed: the translator handed to a form library (see the Deform page of this section) and the facade of a deprecated API. In tests and scripts the stack is managed for you. pyramid.testing.setUp() and tearDown() (or config.begin() and config.end()) push and pop an entry around a unit test, and pyramid.scripting.prepare() (which pshell uses) is a context manager that builds a request, pushes it and returns the environment:

from pyramid import testing
from pyramid.scripting import prepare

with prepare(registry=wsgi.registry) as env:
    print(sorted(env), get_current_request() is env["request"])

test_config = testing.setUp()
print(get_current_registry() is test_config.registry)
testing.tearDown()
print(get_current_request())

It prints ['closer', 'registry', 'request', 'root', 'root_factory'] True, True and None. The threadlocals chapter says that scripts should expect get_current_request() to return None; with the context-manager form of prepare() in 2.1, which I executed, the request it builds is current inside the with block. Do not rely on either reading in portable code: take a request argument. The unit-testing chapter shows setUp and tearDown, and the testing page of this section builds on them.

For comparison, Flask solves the same need with its application and request context objects (Application and Request Lifecycle); Pyramid’s stack is smaller, less magical and explicitly discouraged in application code.

The Component Registry and the ZCA

Under the hood the application registry, config.registry while configuring and request.registry while serving, is a Zope Component Architecture component registry: Pyramid’s Registry is a subclass of zope.interface.registry.Components and also a dictionary, which is where the settings live. The ZCA stores utilities (named or unnamed singletons looked up by interface), adapters (converters from one interface to another) and subscribers (handlers called for a set of objects). Pyramid hides it deliberately: you need not know it to build an application. It is a good tool for building a framework and an opaque one for building an application, in the words of the official chapter, but it is there, and it is a clean way to register a service once and look it up anywhere that has the request.

The registry has the full method API of the ZCA, so you never need the global functions of zope.component:

from zope.interface import Interface, implementer
from pyramid.exceptions import ConfigurationConflictError


class IBookCatalog(Interface):
    def title_of(book_id):
        """Return the title for an id."""


@implementer(IBookCatalog)
class MemoryCatalog:
    def __init__(self, books):
        self.books = books

    def title_of(self, book_id):
        return self.books[book_id]


def register_catalog(config, catalog):
    def register():
        config.registry.registerUtility(catalog, IBookCatalog)

    config.action(("catalog", IBookCatalog), register)  # same discriminator means a conflict


def catalog_view(request):
    catalog = request.registry.getUtility(IBookCatalog)
    other = request.registry.queryUtility(IBookCatalog, name="other")
    return {"title": catalog.title_of("1"), "no_other": other is None}


with Configurator() as config:
    register_catalog(config, MemoryCatalog({"1": "Dune"}))
    config.add_route("catalog", "/catalog")
    config.add_view(catalog_view, route_name="catalog", renderer="json")
    zapp = config.make_wsgi_app()

print(TestApp(zapp).get("/catalog").json)

try:
    with Configurator() as config:
        register_catalog(config, MemoryCatalog({}))
        register_catalog(config, MemoryCatalog({}))
except ConfigurationConflictError as exc:
    print("conflict:", str(exc).splitlines()[0])

The first print is {'title': 'Dune', 'no_other': True}; the second is conflict: Conflicting configuration actions. Registering through config.action (with a discriminator) rather than calling registerUtility straight away puts the registration into Pyramid’s two-phase configuration: two includes that register the same utility conflict at start-up and the includer can override them, exactly as for routes and views (see the configuration page). A direct call is simpler and never conflicts, but whichever runs last silently wins.

Adapters and handlers use the same registry methods. registerAdapter(factory, required, provided) plus queryAdapter(obj, interface) returns None when no adapter is registered (getAdapter raises instead), and registerHandler(handler, required) plus subscribers(objects, None) calls every matching handler:

class IBookSummary(Interface):
    pass


class Book:
    def __init__(self, title):
        self.title = title


@implementer(IBookSummary)
class BookSummary:
    def __init__(self, book):
        self.book = book

    def text(self):
        return f"[{self.book.title}]"


registry = zapp.registry
registry.registerAdapter(BookSummary, (Book,), IBookSummary)
print(registry.queryAdapter(Book("Dune"), IBookSummary).text(), registry.queryAdapter(object(), IBookSummary))

handled = []
registry.registerHandler(lambda book: handled.append(book.title), (Book,))
registry.subscribers((Book("Hyperion"),), None)
print(handled)

The output is [Dune] None and ['Hyperion']. Pyramid’s own events (NewRequest, ContextFound, NewResponse, BeforeRender) are subscribers on this same registry; you normally register them with config.add_subscriber, which adds predicate support and conflict handling on top.

The ZCA Global API and hook_zca

The functions of zope.component (getUtility, getAdapter, getSiteManager) consult the ZCA global registry by default, and Pyramid creates a separate registry per application so that several applications can live in one process, so those calls do not see your configuration. The documented fixes are to avoid the global API (what the previous examples do), to call config.hook_zca(), or to hand the ZCA global registry to the Configurator. hook_zca() imports zope.component, so install that package first; Pyramid does not depend on it.

from zope.component import getUtility, getSiteManager
from zope.interface.interfaces import ComponentLookupError

try:
    getUtility(IBookCatalog)
except ComponentLookupError:
    print("global API does not see the application registry")

hook = Configurator(registry=zapp.registry)
hook.hook_zca()
with prepare(registry=zapp.registry):
    print(getUtility(IBookCatalog).title_of("1"), getSiteManager() is zapp.registry)
print(getSiteManager() is zapp.registry)
hook.unhook_zca()
try:
    getUtility(IBookCatalog)
except ComponentLookupError:
    print("hook removed")

The output is global API does not see the application registry, Dune True, False and hook removed. The hook makes getSiteManager() return Pyramid’s current registry, so it works in threads that are processing a request (here inside prepare) and falls back to the process-wide global elsewhere. unhook_zca() undoes it. The hook changes a process-wide setting: if a Zope application shares the process, its calls start using the Pyramid registry, "inverting the original problem". For that case the third option applies: create the application on the ZCA global registry itself with from zope.component import getGlobalSiteManager, then config = Configurator(registry=getGlobalSiteManager()) followed by config.setup_registry(settings=settings) (the call that normally happens for a registry Pyramid creates). Only do this when you need a single, process-wide registry: it gives up the ability to run two applications side by side and makes tests share state.

Hybrid Applications: Edge Cases

The traversal page explains the four differences between pure traversal and a hybrid application and shows a route that ends in *traverse. This section adds the official "Hello Traversal World" and the corners that surprise people.

Hello Traversal World

The smallest traversal application has no route at all. The URL space is the tree under the root factory, and a view registered for context=Resource displays any node:

from pyramid.response import Response


class Resource(dict):
    __name__ = None
    __parent__ = None

    def add(self, name, child=None):
        child = Resource() if child is None else child
        child.__name__, child.__parent__ = name, self
        self[name] = child
        return child


def get_root(request):
    root = Resource()
    ada = root.add("ada")
    ada.add("dune")
    return root


def show(context, request):
    return Response(f"resource {context.__name__!r} with children {sorted(context)}")


with Configurator() as config:
    config.set_root_factory(get_root)
    config.add_view(show, context=Resource)
    hello = TestApp(config.make_wsgi_app())

for path in ("/", "/ada", "/ada/dune"):
    print(path, hello.get(path).text)
print(hello.get("/ada/nope", status=404).status)

The output is / resource None with children ['ada'], /ada resource 'ada' with children ['dune'], /ada/dune resource 'dune' with children [] and 404 Not Found. A key that does not exist ends traversal with a view name that no view answers, which becomes a 404. The official version of this example (in "Hello Traversal World") builds a fixed tree of Resource dictionaries without __name__ and __parent__; the two attributes added here are what request.resource_url needs to build links.

What a Matched Route Changes

The next application mounts the same tree under several routes, each showing one edge case. The trace view reports what traversal produced:

def trace(context, request):
    return {
        "context": context.__name__,
        "view_name": request.view_name,
        "subpath": list(request.subpath),
        "route": request.matched_route.name if request.matched_route else None,
    }


def link(context, request):
    return {
        "plain": request.resource_url(context),
        "routed": request.resource_url(context, route_name="api", route_kw={"version": "v2"}),
        "slashed": request.resource_url(context, route_name="slashed", route_kw={"version": "v2"}),
        "other_name": request.resource_path(context, route_name="files", route_remainder_name="rest"),
    }


with Configurator(root_factory=get_root) as config:
    config.add_route("api", "/api/{version}*traverse")
    config.add_route("slashed", "/s/{version}/*traverse")
    config.add_route("both", "/both/{shelf}/*traverse", traverse="/{shelf}/dune")
    config.add_route("write", "/w/*traverse", request_method="POST")
    config.add_route("global", "/g/*traverse", use_global_views=True)
    config.add_route("files", "/files*rest")
    config.add_route("static", "/static/*subpath")
    config.add_view(trace, route_name="api", renderer="json")
    config.add_view(trace, route_name="api", name="edit", renderer="json")
    config.add_view(link, route_name="api", name="link", renderer="json")
    config.add_view(trace, route_name="both", renderer="json")
    config.add_view(trace, route_name="static", renderer="json")
    config.add_view(trace, name="plain", renderer="json")
    config.add_view(show, context=Resource)
    hybrid = TestApp(config.make_wsgi_app())

for path in (
    "/api/v1/ada/dune",
    "/api/v1/ada/dune/edit/extra/more",
    "/api/v1/ada/missing",
    "/both/ada/anything",
    "/g/ada/plain",
    "/api/v1/plain",
    "/plain",
    "/static/css/site.css",
):
    res = hybrid.get(path, expect_errors=True)
    print(path, res.status_int, res.json if res.content_type == "application/json" else "")
print(hybrid.get("/api/v1/ada/link").json)
print(hybrid.get("/w/ada", expect_errors=True).status_int, hybrid.post("/w/ada", expect_errors=True).status_int)

The eight lines of the loop show these behaviours:

Request Result Why

/api/v1/ada/dune

200, context dune, view name empty

*traverse captured ada/dune; the default view of the route runs.

/api/v1/ada/dune/edit/extra/more

200, view name edit, subpath extra, more

The path ran out of tree at edit, which became the view name, and the rest became request.subpath.

/api/v1/ada/missing

404

View name missing has no view with this route name. The global show view registered without route_name is not considered under a hybrid route.

/both/ada/anything

200, context dune, view name empty

The route has both a *traverse star and a traverse= argument. The chapter says the argument is ignored then, but in the 2.1 code I ran it won: traversal followed /ada/dune and the captured ada/anything was not used. Do not combine the two.

/g/ada/plain

200, context ada, view name plain

use_global_views=True makes views without route_name visible to the route.

/api/v1/plain

404

The plain view has no route_name and the route does not enable global views.

/plain

200, no route

No route matched, so this is plain traversal and the global view answers.

/static/css/site.css

200, subpath css, site.css

*subpath performs no traversal but fills request.subpath; the context is the root.

The two final lines print the link dictionary and 404 404. /w/ada has a route restricted to POST; a GET does not match that route and falls through to pure traversal, which finds no w child in the tree (404). A POST matches the route, but no view names it, so that is a 404 too: a route without a view for it is a dead end, not a fall-through to other routes.

The generated links teach two further points. request.resource_url(context, route_name=..., route_kw=...) builds a URL under the route; route_kw fills the other dynamic parts and route_remainder_name names the star argument when it is not traverse. For the resource ada, the plain link has no route and is http://localhost/ada/; the routed link under /api/{version}*traverse is http://localhost/api/v2/ada/ and the other_name path is /files/ada/, but the pattern /s/{version}/*traverse that writes a slash before the star produces a double slash (http://localhost/s/v2//ada/), because the traversal path already starts with one. Write the star directly after the last literal or marker, /api/{version}*traverse. Finally, passing route_name makes resource_url ignore a __resource_url__ method on the resource, which is documented as incompatible with the feature.

Pitfalls and Security Checklist

Pitfall What to do

An exception from a subrequest escapes the exception view

Pass use_tweens=True, or catch the exception around invoke_subrequest.

A subrequest answers 403 although the user is logged in

A blank request carries no identity. Copy the cookie, header or environ entry that your security policy reads.

Subrequest URLs built from user input

Never. A subrequest reaches any route of the application; use fixed paths.

use_tweens=True commits a pyramid_tm transaction in the middle of the outer one

Know your tween list; use use_tweens=False for read-only composition.

Audit code finds nothing

The Configurator was created with introspection=False.

Private pages carry public or a long max-age

Use http_cache=0 or no-store for user-specific responses and name Vary headers; never public for authenticated content.

pyramid.prevent_http_cache left on in production

It removes every http_cache header and is intended for development or when a front end owns caching.

A value computed twice per request

Wrap the loader in RequestLocalCache; clear it after anything that changes its inputs.

Background thread sees no request

Thread locals are per thread; pass the request data explicitly.

get_current_request() in application code

Take request as a parameter; keep thread locals for tests, scripts and facades.

getUtility fails with ComponentLookupError

Use request.registry.getUtility, or hook_zca() and know that it is process-wide.

Two includes register the same utility

Register through config.action with a discriminator so the conflict is detected.

Hybrid view not found

Give the view route_name, or set use_global_views=True on the route.

// in generated hybrid URLs

Remove the slash before the star in the route pattern.

Summary

  • request.invoke_subrequest(Request.blank(path), use_tweens=...) runs another view through the full view pipeline; without tweens exceptions propagate, and the identity must be copied by hand.

  • The introspector at request.registry.introspector lists routes, views, permissions and more; it is the base of audits and tools, and introspection=False empties it.

  • http_cache writes Expires and Cache-Control; prevent_auto opts one response out, and pyramid.prevent_http_cache switches the feature off for the whole application.

  • RequestLocalCache caches a value for one request and releases it automatically.

  • Thread locals hold the current request and registry per thread and per subrequest; keep them out of application code and pass the request instead.

  • The application registry is a ZCA component registry: use its methods (registerUtility, registerAdapter, registerHandler) directly, register through config.action to get conflict detection, and treat hook_zca() as a last resort.

  • Hybrid routes limit view lookup to views named after the route, behave unpredictably when both a *traverse star and a traverse= argument are given, and produce clean links only when the star follows its prefix without a slash.

The deployment of an application that uses these features (a WSGI server such as Waitress behind a reverse proxy, in a container) is described in the production pages of this section, and the Tornado page on WSGI interoperability shows how a WSGI application such as Pyramid can be served next to asynchronous handlers. For packaging see Docker and Kubernetes; for the other Python frameworks of this site see FastAPI and Django.