Static Assets

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 (with pyramid_jinja2 2.10.1, WebTest 3.0.7 and setuptools below 82) on Python 3.13, checked on 2026-10-07. Every Python example here was executed with webtest.TestApp or, where response encodings matter, with WebOb’s own Request.get_response, and every name was checked against the official narrative chapter on static assets, the API pages and the installed package source. The running example is the Bookshelf Shelves scenario: public shelves of books whose pages need a stylesheet, a script, a favicon and a handful of images, first on a laptop and then behind a CDN.

A web framework spends most of its requests on files that never change per user: stylesheets, scripts, images and fonts. Pyramid treats them as assets, files that live inside a Python package, and gives you three things: a way to address them independently of the filesystem (the asset specification), a way to serve them (add_static_view, an ordinary route plus view underneath) and a way to link to them (request.static_url), which is also the hook for cache busting and for moving the files to a CDN without touching a template. The serving part is deliberately modest. It is correct and convenient in development, and in production it is usually replaced by a reverse proxy or a CDN while the URL-generation part stays exactly as it is.

flowchart LR T["template or view calls request.static_url(spec)"] --> G[StaticURLInfo.generate] G --> B{cache buster registered for this spec?} B -- yes --> M[buster rewrites subpath or adds a query token] B -- no --> U M --> U[URL: local prefix or external CDN base] U --> C[client requests the URL] C --> S[static_view served through route and view] S --> F[FileResponse with headers]

Asset Specifications

How a static URL is generated and how the file is served; with an external base URL the last two steps happen outside the application.

An asset is any file inside a Python package that is not a Python source file: a stylesheet, an image, a template. Pyramid addresses assets with an asset specification, written package:path/inside/the/package, for example bookshelf_shelves:static/css/shelf.css. The part before the colon is a dotted package name, the part after it is a /-separated path relative to the package directory. Pyramid resolves it with the pkg_resources API (Pyramid 2.1 still imports it, which is why 2.1 needs a setuptools older than 82), which is also what makes assets overridable (see Overriding Assets) and what lets a package installed as a wheel or zip serve its own files.

There is a second, relative form without a package name (static/css/shelf.css). Pyramid APIs that accept it resolve it against a "current package" that each API documents: usually the package that contains the code making the call. Relative specifications are convenient in a single package and fragile in shared helper code, so this page uses full specifications everywhere. Where a Pyramid API takes a filesystem path, it generally also accepts an absolute path, with the caveats noted below.

The examples need a package to point at. In a real project the cookiecutter has already created static/ inside the application package (see Cookiecutter Projects & pserve). This first block creates a small equivalent in a temporary directory so that the rest of the page can be run unchanged: a stylesheet and its gzipped twin, a script, an index.html, a favicon and a second package that will play the role of a theme later.

import gzip
import sys
import tempfile
from pathlib import Path

root = Path(tempfile.mkdtemp())
pkg = root / "shelves_demo"
theme = root / "theme_demo"
for sub in ("static/css", "static/js", "templates"):
    (pkg / sub).mkdir(parents=True)
(theme / "static/css").mkdir(parents=True)
(pkg / "__init__.py").write_text("")
(theme / "__init__.py").write_text("")

css = "body { font-family: Georgia, serif; }\n" * 40
(pkg / "static/css/shelf.css").write_text(css)
(pkg / "static/css/shelf.css.gz").write_bytes(gzip.compress(css.encode()))
(pkg / "static/js/app.js").write_text("console.log('shelves');\n")
(pkg / "static/index.html").write_text("<h1>Shelves assets</h1>\n")
(pkg / "static/favicon.ico").write_bytes(b"\x00\x00\x01\x00")
(theme / "static/css/shelf.css").write_text("body { font-family: monospace; }\n")

sys.path.insert(0, str(root))

Serving Assets with add_static_view

Configurator.add_static_view(name, path, **kw) registers a directory of files so that they are served below a URL prefix. name is the prefix (static serves /static/...) and path is the directory, as an asset specification or an absolute path. Everything under that directory is served recursively, the Content-Type comes from the file extension, and by default the files are reachable by anonymous users. Underneath, Pyramid does nothing exotic: it registers a route named __static/ (the name with a double underscore in front and a trailing slash) whose pattern is static/*subpath, and a view on that route whose callable is an instance of pyramid.static.static_view created with use_subpath=True. Because it is an ordinary route, it takes part in route ordering, shows up in proutes, accepts route_prefix from config.include and can be given any add_route keyword argument (such as factory) through the keyword arguments of add_static_view.

Argument Meaning

name

URL prefix relative to the application root (static), or a complete URL (https://cdn.example.com/shelves/) when the files are hosted elsewhere. A protocol-relative URL such as //cdn.example.com/shelves takes the scheme of the request.

path

Directory to serve: asset specification (pkg:static) or absolute path. Only directories, never a single file, and only below the prefix, never directly at the root of the site.

cache_max_age

Seconds for the Cache-Control: max-age and Expires headers. Default None: no cache headers at all. It has no effect when name is a URL, because Pyramid then does not serve the files.

content_encodings

List of encodings for which precompressed siblings exist (["gzip"]); see Precompressed Assets. Added in 2.0.

permission

Permission required to read the files. Default: no permission needed, even when the application has a default permission.

any other keyword

Passed to add_route (factory, pregenerator, …​).

The block below serves the demo package and shows the pieces working together: a view that links to two assets, the files served with the right content type and no cache headers, the directory URL answered with index.html, and a missing file and a path-escape attempt both answered with 404.

from pyramid.config import Configurator
from pyramid.response import Response
from webtest import TestApp


def home(request):
    css = request.static_url("shelves_demo:static/css/shelf.css")
    js = request.static_path("shelves_demo:static/js/app.js")
    return Response(f"{css} {js}")


with Configurator() as config:
    config.add_static_view(name="static", path="shelves_demo:static")
    config.add_route("home", "/")
    config.add_view(home, route_name="home")
    app = config.make_wsgi_app()

client = TestApp(app)
assert client.get("/").text == "http://localhost/static/css/shelf.css /static/js/app.js"

res = client.get("/static/css/shelf.css")
assert res.content_type == "text/css"
assert "Cache-Control" not in res.headers  # cache_max_age defaults to None

assert client.get("/static/").text.startswith("<h1>")  # index.html of the directory
client.get("/static/missing.css", status=404)
client.get("/static/%2e%2e/__init__.py", status=404)  # cannot leave the directory

A few behaviours follow from that implementation and are worth knowing before they surprise you.

  • The view resolves request.subpath, rejects any path that would leave the directory and answers 404. A request for a directory without a trailing slash is redirected (301) to the slash form and a directory URL serves the file named by the view’s index (index.html) if it exists; there is no directory listing.

  • Static views are matched by their route, which is added at the point of the add_static_view call. A /{name} route registered before it can shadow /static/..., so keep the static view near the top of the configuration.

  • A package that does not exist is a deprecation in 2.1: passing a path such as nosuchpkg:static still works but warns that it will break in the future; point the view at a directory inside a real package instead (see Overriding Assets for the supported way to serve files from elsewhere).

  • Files added to the directory while the process runs are not noticed by default because the view caches the list of files it has resolved. The pyramid.reload_assets setting (environment variable PYRAMID_RELOAD_ASSETS) turns that cache off, which suits development together with pserve --reload (the generated development.ini switches on pyramid.reload_templates only); leave it off in production. The settings are covered in Settings, Environment & .ini Files.

  • Pyramid’s own static view is a Python WSGI application served by your worker processes. It reads the file and streams it with WSGI’s file wrapper where the server supports it, which is perfectly fine for a small site, a development machine and a test suite, and it is the wrong place to spend worker time for a busy site; see Serving Assets in Production.

Generating Static URLs

You should never write /static/css/shelf.css by hand. request.static_url(spec, **kw) returns an absolute URL and request.static_path(spec, **kw) the same URL without scheme and host; both take the asset specification of the file and look up which static view or external base it belongs to. They accept the keyword arguments of route_url: _query, _anchor, _app_url, _scheme, _host and _port. The benefit is the one every URL-generation API offers: if you rename the prefix, move the view behind a route_prefix or switch to an external base URL, every generated link follows.

If no static view or external base matches the specification, static_url raises ValueError (No static URL definition matching ...), which is the usual symptom of a typo in the package name or of calling it for a file outside any registered directory. The functions pyramid.url.static_url(path, request) and static_path(path, request) are older spellings of the same API.

def urls(request):
    return Response("\n".join([
        request.static_url("shelves_demo:static/js/app.js", _query={"v": "2"}, _anchor="top"),
        request.static_path("shelves_demo:static/js/app.js"),
    ]))


with Configurator() as config:
    config.add_static_view("static", "shelves_demo:static")
    config.add_route("urls", "/urls")
    config.add_view(urls, route_name="urls")
    app = config.make_wsgi_app()

lines = TestApp(app).get("/urls").text.splitlines()
assert lines == ["http://localhost/static/js/app.js?v=2#top", "/static/js/app.js"]

try:
    with Configurator() as config:
        config.add_route("only", "/only")
        config.add_view(lambda request: Response(request.static_url("shelves_demo:static/x.css")), route_name="only")
        app = config.make_wsgi_app()
    TestApp(app).get("/only")
except ValueError as exc:
    assert "No static URL definition" in str(exc)

In a template the same call is available through the template engine’s helpers. With pyramid_jinja2 the package provides filters static_url_filter and static_path_filter in pyramid_jinja2.filters, but they are not active until you register them under names with the jinja2.filters setting. (The cookiecutter project skips the filters and calls {{ request.static_url('bookshelf_shelves:static/theme.css') }} in its layout template, which works in any engine.) The template below is written to the demo package and rendered through a view, so the HTML contains the URL, busted or not, that the registered static view produces. In Jinja the expression is written {{ 'shelves_demo:static/css/shelf.css'|static_url }}; with Chameleon or Mako you call request.static_url(...) directly, as the Getting Started page does for routes. The template engines themselves are the subject of the Templates page.

(pkg / "templates/layout.jinja2").write_text(
    '<link rel="stylesheet" href="{{ \'shelves_demo:static/css/shelf.css\'|static_url }}">\n'
)

jinja_settings = {
    "jinja2.filters": "\n".join([
        "static_url = pyramid_jinja2.filters:static_url_filter",
        "static_path = pyramid_jinja2.filters:static_path_filter",
    ])
}
with Configurator(settings=jinja_settings) as config:
    config.include("pyramid_jinja2")
    config.add_static_view("static", "shelves_demo:static")
    config.add_route("layout", "/layout")
    config.add_view(lambda request: {}, route_name="layout", renderer="shelves_demo:templates/layout.jinja2")
    app = config.make_wsgi_app()

html = TestApp(app).get("/layout").text
assert 'href="http://localhost/static/css/shelf.css"' in html

Cache Headers

Browsers cache what you tell them to cache, and Pyramid tells them nothing by default. add_static_view passes cache_max_age=None to the view it creates, so a response carries Last-Modified and Content-Length but no Cache-Control and no Expires. (The class pyramid.static.static_view, used directly, defaults to 3600 seconds. The two defaults differ, which is easy to forget when you move a view from one form to the other.) The browser then revalidates with If-Modified-Since on each use, which costs a round trip per file but never serves stale content.

Setting cache_max_age switches that to "trust the cache for this long". That is only safe when the URL changes whenever the content does, which is the whole point of the next section; a long cache_max_age on stable URLs means visitors see old stylesheets until their cache expires.

with Configurator() as config:
    config.add_static_view("static", "shelves_demo:static", cache_max_age=86400)
    app = config.make_wsgi_app()

client = TestApp(app)
res = client.get("/static/js/app.js")
assert res.headers["Cache-Control"] == "max-age=86400"
assert "Expires" in res.headers

# a conditional request for an unchanged file is answered without a body
again = client.get("/static/js/app.js", headers={"If-Modified-Since": res.headers["Last-Modified"]}, status=304)
assert again.body == b""

Cache Busting

The combination that gives the best performance is a very long cache lifetime plus URLs that change when the file changes. Pyramid’s hook for that is config.add_cache_buster(path, cachebust, explicit=False): it registers an object implementing pyramid.interfaces.ICacheBuster, a callable (request, subpath, kw) that returns a possibly modified (subpath, kw), for every asset below path. From then on static_url and static_path run the buster before building the URL. Only URL generation is affected. The buster does not serve anything, so the files must still be reachable under the URL it produces. path is a directory (the trailing slash is added for you) and the longest matching registration wins.

Pyramid ships three implementations.

Class Behaviour Typical use

QueryStringConstantCacheBuster(token, param='x')

Adds the same token to every asset URL: /static/js/app.js?x=v42.

Quick start, deployment version number. A coarse tool: any change invalidates everything.

QueryStringCacheBuster(param='x')

Base class: you implement tokenize(request, subpath, kw) and return a token.

Per-file tokens (modification time, content hash) computed by the application.

ManifestCacheBuster(manifest_spec, reload=False)

Looks the subpath up in a JSON manifest written by your build tool; unknown paths pass through unchanged.

Hashed file names produced by an asset pipeline; works when the application cannot see the files.

The constant buster and a per-file buster look like this. The second class reads the file through an AssetResolver and returns the first eight hex digits of its SHA-256; the token is cached because the file does not change while the process runs. Note the signature of tokenize: the installed source calls it with (request, subpath, kw), and the pathspec of the asset is in kw. (The git-hash example in the narrative chapter still shows an older tokenize(self, pathspec) form, which would fail with that call.)

import hashlib

from pyramid.path import AssetResolver
from pyramid.static import QueryStringCacheBuster, QueryStringConstantCacheBuster


class ContentHashCacheBuster(QueryStringCacheBuster):
    """Token = first 8 hex digits of the SHA-256 of the file's content."""

    def __init__(self, param="h"):
        super().__init__(param=param)
        self._tokens = {}

    def tokenize(self, request, subpath, kw):
        pathspec = kw["pathspec"]
        if pathspec not in self._tokens:
            data = Path(AssetResolver().resolve(pathspec).abspath()).read_bytes()
            self._tokens[pathspec] = hashlib.sha256(data).hexdigest()[:8]
        return self._tokens[pathspec]


def links(request):
    return Response("\n".join([
        request.static_url("shelves_demo:static/js/app.js"),
        request.static_path("shelves_demo:static/css/shelf.css"),
    ]))


def build(buster, settings=None):
    with Configurator(settings=settings) as config:
        config.add_static_view("static", "shelves_demo:static")
        config.add_cache_buster("shelves_demo:static", buster)
        config.add_route("links", "/links")
        config.add_view(links, route_name="links")
        return TestApp(config.make_wsgi_app())


constant = build(QueryStringConstantCacheBuster("v42")).get("/links").text.splitlines()
assert constant == ["http://localhost/static/js/app.js?x=v42", "/static/css/shelf.css?x=v42"]

hashed = build(ContentHashCacheBuster()).get("/links").text.splitlines()
digest = hashlib.sha256(b"console.log('shelves');\n").hexdigest()[:8]
assert hashed[0] == f"http://localhost/static/js/app.js?h={digest}"

Query-string busters are simple but have a well-known weakness: many caching proxies decline to cache a URL that has a query string. The narrative chapter therefore recommends changing a path segment when possible, which means renaming the file, which in turn means the file must exist under the new name. That is the job of a build tool (hash the content, write shelf.7f3a1c.css, record the mapping in a manifest), and of ManifestCacheBuster on the Pyramid side. The manifest is a flat JSON object from the source path to the hashed path. The example writes one, registers the buster and shows both the generated URL and that the hashed file is what must be served:

import json

from pyramid.static import ManifestCacheBuster

(pkg / "static/manifest.json").write_text(json.dumps({"css/shelf.css": "css/shelf.7f3a1c.css"}))
(pkg / "static/css/shelf.7f3a1c.css").write_text(css)  # produced by the build tool

client = build(ManifestCacheBuster("shelves_demo:static/manifest.json"))
paths = client.get("/links").text.splitlines()
assert paths == ["http://localhost/static/js/app.js", "/static/css/shelf.7f3a1c.css"]  # unlisted paths are unchanged
client.get("/static/css/shelf.7f3a1c.css")  # the application (or the CDN) must serve the hashed name
client.get("/static/css/shelf.css")  # the original name still works for old HTML

Three details of ManifestCacheBuster matter in practice. With the default reload=False the manifest is read immediately, so a missing file stops the application at startup (a crash at boot is better than links that silently lack their hashes). With reload=True the file is re-read when it changes and a missing file counts as an empty mapping; that is a development convenience and the class documentation advises against it in production. And because the format is whatever parse_manifest(content) returns, a subclass adapts other tools. Build tools such as Vite write a manifest whose values are objects rather than strings; a two-line override turns that into the flat mapping:

class ViteManifestCacheBuster(ManifestCacheBuster):
    def parse_manifest(self, content):
        entries = json.loads(content.decode("utf-8"))
        return {src: entry["file"] for src, entry in entries.items()}


(pkg / "static/vite-manifest.json").write_text(json.dumps({"js/app.js": {"file": "js/app-4ed993c7.js"}}))
buster = ViteManifestCacheBuster("shelves_demo:static/vite-manifest.json")
assert buster.manifest == {"js/app.js": "js/app-4ed993c7.js"}

Cache busting only rewrites the URLs that Pyramid generates. References inside stylesheets and scripts (url(img/cover.png), dynamic import()) are invisible to it, because Pyramid knows nothing about their content. The narrative chapter’s advice is to let the asset pipeline rewrite those references into their final hashed form, so that the files stay truly static and can be hosted by Pyramid or by a CDN without changes; templating the CSS and calling static_url inside it works but makes your "static" files depend on the application.

To switch every buster off in development without editing the calls, set the pyramid.prevent_cachebust setting or the PYRAMID_PREVENT_CACHEBUST environment variable to a true value. The decision is made when the registrations are committed, so it applies to the whole process.

quiet = build(QueryStringConstantCacheBuster("v42"), settings={"pyramid.prevent_cachebust": "true"})
assert quiet.get("/links").text.splitlines() == ["http://localhost/static/js/app.js", "/static/css/shelf.css"]

Serving Assets from a CDN or Another Host

When name of add_static_view is a URL, Pyramid registers no route and no view. It only remembers the base URL, and static_url produces links beneath it. The files still have to be there (copied to a bucket, or served by another web server), and that server must be configured to answer the generated URLs; Pyramid does not check. The cache_max_age argument is ignored because Pyramid is not serving the files, so cache headers are the job of the CDN or server. Together with a cache buster the setup is typically "hashed names, long lifetime, CDN in front".

The narrative chapter suggests reading the base from a custom setting so that the same code serves from the application in development and from a CDN in production. Since the setting value is just a URL prefix, one line of the .ini file moves the assets:

# development.ini: served by the application at /static/...
media_location = static

# production.ini: served by the CDN
media_location = https://cdn.example.com/shelves/
def external(settings):
    with Configurator(settings=settings) as config:
        config.add_static_view(name=settings["media_location"], path="shelves_demo:static")
        config.add_route("links", "/links")
        config.add_view(links, route_name="links")
        return TestApp(config.make_wsgi_app())


cdn = external({"media_location": "https://cdn.example.com/shelves/"})
assert cdn.get("/links").text.splitlines() == [
    "https://cdn.example.com/shelves/js/app.js",
    "https://cdn.example.com/shelves/css/shelf.css",
]
cdn.get("/shelves/js/app.js", status=404)  # nothing is served locally

relative = external({"media_location": "//cdn.example.com/shelves"})  # takes the request scheme
assert relative.get("/links").text.splitlines()[0] == "http://cdn.example.com/shelves/js/app.js"

Precompressed Assets

Compressing text assets on every request wastes CPU, and compressing them once at build time is free. Since Pyramid version 2.0, content_encodings=["gzip"] tells the static view that shelf.css.gz is the gzip form of shelf.css. When the client’s Accept-Encoding includes an encoding for which a sibling file exists, the view serves the smallest acceptable file and sets Content-Encoding accordingly; if the sibling is missing or the client does not accept the encoding, the plain file is sent. When any alternative exists the response also gets Vary: Accept-Encoding, which caches need to keep the two forms apart. The file extensions are looked up in mimetypes.encodings_map: .gz, .Z, .bz2, .xz and .br are present on Python 3.13, so content_encodings=["br"] works with .br files; if an encoding you need is missing, add it to the dictionary before configuring the view.

WebTest’s TestApp decodes a gzipped body and drops the Content-Encoding header before your assertion sees it, so this particular test uses WebOb’s Request.get_response against the WSGI app instead.

from webob import Request

with Configurator() as config:
    config.add_static_view("static", "shelves_demo:static", content_encodings=["gzip"])
    app = config.make_wsgi_app()

gz = Request.blank("/static/css/shelf.css", headers={"Accept-Encoding": "gzip"}).get_response(app)
assert gz.headers["Content-Encoding"] == "gzip"
assert gzip.decompress(gz.body).decode() == css
assert gz.headers["Vary"] == "Accept-Encoding"

plain = Request.blank("/static/css/shelf.css").get_response(app)
assert "Content-Encoding" not in plain.headers and plain.text == css

other = Request.blank("/static/js/app.js", headers={"Accept-Encoding": "gzip"}).get_response(app)
assert "Content-Encoding" not in other.headers  # no app.js.gz exists: the plain file is served

Protecting Assets with a Permission

By default a static view is open to everyone, even in an application that sets a default permission. Passing permission to add_static_view requires that permission for every file in the directory; the check is made by the application’s security policy against the default root factory’s ACL (or against the factory you pass through the keyword arguments, since they go to add_route). The security machinery is the subject of the security pages of this section; the example uses the smallest possible policy, with a request header standing in for authentication, which is only acceptable in a test:

from pyramid.security import Allowed, Denied


class ReaderHeaderPolicy:
    """Toy policy for the test: whoever sends X-Reader: ada may read."""

    def identity(self, request):
        return request.headers.get("X-Reader")

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

    def permits(self, request, context, permission):
        if self.identity(request) == "ada":
            return Allowed("reader")
        return Denied("anonymous")

    def remember(self, request, userid, **kw):
        return []

    def forget(self, request, **kw):
        return []


with Configurator() as config:
    config.set_security_policy(ReaderHeaderPolicy())
    config.add_static_view("private", "shelves_demo:static", permission="read")
    app = config.make_wsgi_app()

client = TestApp(app)
client.get("/private/js/app.js", status=403)
assert client.get("/private/js/app.js", headers={"X-Reader": "ada"}).status_int == 200

A permission on a directory is the simple case. When the rule depends on the individual file (a book’s cover that only its owner may see, an uploaded manuscript), do not put the files in a static directory at all: write a view callable that checks the rule and returns a pyramid.response.FileResponse. User uploads in particular should live outside the package and be served by such a view, never by a public static view, and never under a URL that lets the user choose the file name or content type.

Static Files from a View Callable

add_static_view cannot serve one file, and it cannot serve a directory at the root of the site. Both are done with plain views, and pyramid.static.static_view is the same helper that add_static_view uses, available for direct use. The favicon is the classic single file: a view returns a FileResponse, which sets the length and the content type from the file extension and serves the file as fast as the server allows. A catch-all static view at the root is a static_view instance with use_subpath=True registered on a route /*subpath that comes last in the routing table, because it matches everything.

from pyramid.response import FileResponse
from pyramid.static import static_view


def favicon(request):
    return FileResponse(str(pkg / "static/favicon.ico"), request=request)


def api(request):
    return Response("api")


with Configurator() as config:
    config.add_route("favicon", "/favicon.ico")
    config.add_view(favicon, route_name="favicon")
    config.add_route("api", "/api")
    config.add_view(api, route_name="api")
    # every other add_route comes before this one: it matches all paths
    config.add_route("catchall_static", "/*subpath")
    config.add_view(static_view("shelves_demo:static", use_subpath=True), route_name="catchall_static")
    app = config.make_wsgi_app()

client = TestApp(app)
assert client.get("/favicon.ico").content_type.endswith("icon")
assert client.get("/api").text == "api"
assert client.get("/js/app.js").text.startswith("console.log")

Two caveats from the chapter. static_url cannot generate URLs for files served this way, because it only knows the directories registered with add_static_view. And the root-relative form does not work in an application that uses traversal: the route matches before traversal starts and would swallow every view without a route_name; there the static_view has to be registered as the Not Found view instead.

Overriding Assets

config.override_asset(to_override, override_with) lets one package replace assets of another without forking it. It works for single files, for directories (both specifications must end in a slash) and for the static views built on those packages. This is how a deployment replaces a logo or a stylesheet in a reusable application, and how themes stack: several calls naming the same to_override form a search path in which the first existing file wins and the original is the fallback. The specification may omit the colon and path to redirect a whole package, may start with a dot to be relative to the configuring package, and may be an absolute filesystem path, which is the supported way to serve files that live outside any package. A static view or static_url then keeps working on the original specification.

with Configurator() as config:
    config.add_static_view("static", "shelves_demo:static")
    config.add_route("links", "/links")
    config.add_view(links, route_name="links")
    config.override_asset(
        to_override="shelves_demo:static/css/shelf.css",
        override_with="theme_demo:static/css/shelf.css",
    )
    app = config.make_wsgi_app()

client = TestApp(app)
assert client.get("/static/css/shelf.css").text == "body { font-family: monospace; }\n"
assert client.get("/static/js/app.js").text.startswith("console.log")  # not overridden

# an absolute path as the override target: serve files that live outside any package
uploads = root / "covers"
uploads.mkdir()
(uploads / "dune.txt").write_text("cover bytes")
with Configurator() as config:
    config.add_static_view("covers", "shelves_demo:covers")  # a dummy directory inside a real package
    config.override_asset(to_override="shelves_demo:covers/", override_with=str(uploads) + "/")
    app = config.make_wsgi_app()
assert TestApp(app).get("/covers/dune.txt").text == "cover bytes"

Two interactions with cache busting are worth knowing. An asset-aware buster such as ManifestCacheBuster has a manifest for the original assets only, so an overridden file would receive the token of the file it replaced. The remedy is add_cache_buster(path, buster, explicit=True) with a second buster for the override package, matched against the raw specification (rawspec, the location of the file actually served) instead of the virtual one. And Pyramid 2.1 changed the lookup for stacked overrides: the cache buster now finds the first file that exists in the override stack, instead of the first override regardless of whether it has the file.

Serving Assets in Production

Pyramid’s static view is a good development server and a bad CDN. In production the standard arrangement is that a reverse proxy (nginx, Apache) or a CDN serves the files straight from disk or object storage and forwards only dynamic requests to the Waitress, Gunicorn or mod_wsgi workers. Because the URLs come from static_url, this is a configuration change and not a code change: either keep name='static' and let the proxy answer /static/ before the request reaches the application, or switch name to the CDN URL as in the previous section. Pair it with a cache buster and a long lifetime. The proxy needs the files on its own disk, so the build or the container image must put them there (the same hashed names as the manifest says).

# nginx in front of the application server on 127.0.0.1:6543
location /static/ {
    alias /srv/bookshelf_shelves/static/;   # the directory behind add_static_view
    expires 1y;                              # only with hashed or versioned URLs
    gzip_static on;                          # serve shelf.css.gz when the client accepts gzip
}

location / {
    proxy_pass http://127.0.0.1:6543;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
}

The gzip_static directive belongs to an optional nginx module that must be compiled in; it mirrors what content_encodings does in Pyramid, using the same .gz siblings. Deployment as a whole (Waitress, Gunicorn, mod_wsgi, proxies and containers) belongs to the deployment page of this section, and the equivalent decisions in other frameworks are described in Flask: Static Files & Assets, Tornado: Static Files and FastAPI: Static Files, Templates & Frontend.

Settings That Affect Static Assets

Setting Environment variable Effect

pyramid.reload_assets

PYRAMID_RELOAD_ASSETS

The static view re-resolves files on each request instead of caching the file list, so new and changed files appear without a restart. Development only.

pyramid.prevent_cachebust

PYRAMID_PREVENT_CACHEBUST

No cache buster is registered, so static_url produces plain URLs. Useful in development and when testing.

pyramid.prevent_http_cache

PYRAMID_PREVENT_HTTP_CACHE

Affects views that use the http_cache view option (see the views page), not static views; do not confuse the two.

Pitfalls and Security Notes

Pitfall What to do

Hand-written /static/... links

They break when the prefix, the route_prefix or the base URL changes and they bypass cache busting. Always generate them with static_url or static_path.

ValueError: No static URL definition matching ...

The specification names a directory that no add_static_view serves, usually a typo in the package name or a file outside the registered directory. Register the view and use the same package name.

A long cache_max_age on stable URLs

Browsers keep serving the old file. Pair long lifetimes only with hashed paths or versioned query strings, and keep cache_max_age=None while the URLs are plain.

A buster’s tokenize copied from older material

Use tokenize(self, request, subpath, kw) and read kw["pathspec"]. The narrative example with a single pathspec argument is stale.

Manifest missing at startup

ManifestCacheBuster reads the file immediately unless reload=True; a build that did not produce it fails the start, which is the safer behaviour.

Hashed URL, file not served

A buster only rewrites URLs. The renamed file must exist in the served directory or on the CDN.

add_static_view for one file or for /

Not supported. Use a FileResponse view or a root-level static_view on a catch-all route registered last (not in traversal applications).

Catch-all route registered too early

It captures every later route. Add it last.

Testing precompressed assets with TestApp

WebTest decodes the body and removes Content-Encoding; use webob.Request.get_response.

Files added while running

Not seen unless pyramid.reload_assets is on. In production restart workers after a deploy anyway.

User uploads in a static directory

A public static view lets anyone fetch anything placed there, with a content type derived from the extension. Keep uploads outside the package, validate extension and size at upload, and serve them from a view that checks authorisation and sets the content type itself (see the files and uploads discussion in the deployment page of this section).

Secrets and source in the static directory

Everything under the served directory is public. Never put .env files, .map files you do not want public, backups or the .ini file beside the assets, and serve only the build output.

pkg_resources deprecation warning

Pyramid 2.1 still imports it; the warning is harmless, and the supported setuptools is below 82. Do not silence a broken asset lookup together with it.

The directory traversal protection of static_view (the %2e%2e request in the first example returns 404) is part of Pyramid, but treat it as a last line of defence: do not feed user input into path or into a FileResponse filename in your own views, and keep static directories free of anything you would not publish.

What Changed and Where Older Material Is Outdated

  • 1.6: cache busters (add_cache_buster, QueryStringConstantCacheBuster, ManifestCacheBuster) and absolute paths for static views and override_asset. Tutorials older than 1.6 versioned URLs by hand or not at all.

  • 2.0: content_encodings for add_static_view and static_view, and the reload option of static_view (what pyramid.reload_assets drives).

  • 2.1: passing a package that does not exist to add_static_view or static_view is deprecated; cache busting with overrides picks the first existing file in the override stack. Both are in the official "What’s New in Pyramid 2.1".

  • Cookiecutter default: the 2.1-branch starter registers config.add_static_view('static', 'static', cache_max_age=3600) with a relative path (resolved against the application package) and one hour of caching on plain URLs, a sensible starting point that you replace with hashed URLs and a longer lifetime once a build step exists.

  • Outdated or inaccurate material: the narrative example of a QueryStringCacheBuster subclass shows tokenize(self, pathspec) while the code calls tokenize(request, subpath, kw); blog posts that call config.add_static_view('static', 'static', cache_max_age=3600) with a relative path rely on the "current package" rule and break when moved into another module; and anything claiming Pyramid generates ETags or compresses on the fly for static files is wrong in 2.1: it sends Last-Modified and serves precompressed siblings you provide.

Comparison with Other Frameworks

Framework How static files are served URL generation and busting

Pyramid

add_static_view (route plus static_view), directory under a prefix, optional permission and precompressed siblings.

request.static_url, pluggable add_cache_buster with manifest support, external base URL for a CDN.

Flask

Built-in static route from static_folder and static_url_path, per-blueprint static folders.

url_for('static', filename=...); cache busting is manual or an extension.

Tornado

StaticFileHandler with static_path.

static_url() appends a content hash automatically.

FastAPI

app.mount('/static', StaticFiles(directory=...)).

No built-in helper beyond url_for.

Django

django.contrib.staticfiles with collectstatic.

{% static %} with ManifestStaticFilesStorage for hashed names.

Pyramid’s distinguishing choices are the asset specification (files addressed by package, so they can be overridden and installed as part of a wheel) and the explicit cache-buster interface. Tornado’s built-in hash and Django’s manifest storage give you busting without a decision; Pyramid asks you to choose and in exchange lets you plug in your own pipeline.

Summary

  • An asset is a file inside a package, addressed by a specification package:path. Use full specifications; Pyramid resolves them with pkg_resources.

  • add_static_view(name, path) is a route plus a static_view; it serves a directory below a prefix, cannot serve one file or the site root, and sends no cache headers unless cache_max_age is set.

  • Generate every link with request.static_url or request.static_path; they follow renames, route_prefix, cache busters and external base URLs.

  • Combine long lifetimes with add_cache_buster: QueryStringConstantCacheBuster for a global token, a QueryStringCacheBuster subclass (tokenize(request, subpath, kw)) for per-file tokens, ManifestCacheBuster for hashed file names. pyramid.prevent_cachebust disables them in development.

  • A URL as name moves the files to a CDN without code changes; content_encodings serves precompressed siblings with Vary: Accept-Encoding.

  • Protect a directory with permission, serve per-file rules and uploads with a view returning FileResponse, and replace assets of other packages with override_asset (stacking, explicit=True for cache busters).

  • In production let nginx or a CDN serve the files and keep the URL generation unchanged.