ZODB & Other Data Stores

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, with ZODB 6.3, persistent 6.8, BTrees 6.5, transaction 5.1, pyramid_zodbconn 0.8.1, zodburi 3.0.0, pyramid_tm 2.6, pyramid_retry 2.1.1, redis-py 8.1.0 and PyMongo 4.18.2. Every Python example was executed with those versions; the ones that need Redis or MongoDB ran against the in-process fakes fakeredis and mongomock, and the ones that need a ZODB database ran against the in-memory storage or a FileStorage file in a temporary directory. The running example is the Bookshelf Shelves scenario, here with its data kept in ZODB instead of a relational database.

Most Python web frameworks assume that data lives in a relational database and give you an ORM for it. Pyramid assumes nothing about persistence: it has no models layer of its own, and the introduction explains why. This page covers the two families of storage that Pyramid applications use besides SQL. The first is ZODB, an object database for Python that stores ordinary Python objects and fits Pyramid’s traversal so naturally that the official wiki tutorial uses it. The second is the group of network data stores, here Redis and MongoDB, that a Pyramid application reaches through a client library it wires in itself. Relational databases with SQLAlchemy have their own page in this section (transactions and migrations); the engines themselves are documented elsewhere on this site and are only linked: SQL, Redis and MongoDB. Here only the Pyramid wiring is covered.

Why ZODB Fits Pyramid

ZODB is a transactional object database. You do not map objects to tables or documents: you subclass persistent.Persistent, hang the instances off a root mapping, and ZODB pickles whatever changed when the transaction commits. There is no query language and no schema; objects are found by walking references from the root (or through indexes that you build, usually with BTrees). That is exactly the access pattern of traversal, where the URL is walked one segment at a time through a tree of containers:

Concern ZODB with traversal Relational database with URL dispatch

Shape of the data

Python objects in a tree; the tree is the URL space

Rows in tables; routes are separate from the schema

Finding an object

__getitem__ along the path, no query

SELECT by key or filter

Schema changes

Python class changes; old pickles must still load

DDL migrations (Alembic)

Ad-hoc queries and reports

Hard: write a traversal or build an index

Easy: SQL

Transactions

The transaction package, joined by pyramid_tm

Same package via zope.sqlalchemy

Scaling out

FileStorage is single process; ZEO, RelStorage or NEO for many processes

Database server

Choose ZODB when the data is naturally hierarchical and mostly read by following references (a content tree, a wiki, a document store, configuration), when objects are rich and change shape often, or when you want transactional persistence with almost no mapping code. Choose SQL when the data is tabular, reported on in aggregate, shared with other applications, or queried in ways you cannot predict. The two are not exclusive: pyramid_tm can drive a ZODB connection and a SQLAlchemy session in the same transaction, because both are data managers of the transaction package.

The Cookiecutter zodb Backend

The pyramid-cookiecutter-starter template (2.1-branch, see Cookiecutter Projects and pserve) asks for a backend (none, sqlalchemy or zodb) and a template language. With zodb it adds four dependencies to pyproject.toml: pyramid_retry, pyramid_tm, pyramid_zodbconn and ZODB (plus transaction). The generated application differs from the plain one in four places:

  • __init__.py includes pyramid_tm, pyramid_retry and pyramid_zodbconn, and sets a root factory that opens a ZODB connection with get_connection(request) and returns the application root.

  • models/__init__.py holds a MyModel class (a PersistentMapping that is its own root) and appmaker(), which creates the root object the first time the database is empty.

  • The .ini files carry zodbconn.uri and retry.attempts, and a [pshell] section whose setup hook starts a transaction for interactive sessions.

  • tests/conftest.py builds the application from testing.ini, with a tm fixture and a testapp fixture that hands the transaction manager to the application.

The generated settings look like this (the file path is relative to the .ini file):

[app:main]
use = egg:bookshelf_shelves
pyramid.reload_templates = true
zodbconn.uri = file://%(here)s/Data.fs?connection_cache_size=20000
retry.attempts = 3

[pshell]
setup = bookshelf_shelves.pshell.setup

production.ini has the same zodbconn.uri line and testing.ini points at a separate Data.testing.fs. The wiki tutorial then replaces MyModel with Wiki and Page classes and runs the same machinery; reading its Basic Layout and Defining the Domain Model chapters next to this page shows the cookiecutter output line by line.

Persistent Objects

An object is stored in ZODB if it is reachable from the database root and its class derives from Persistent. Three rules decide whether changes are saved, taken from the ZODB programming guide:

  • Attributes that are immutable (strings, numbers, tuples) are always safe.

  • Attributes that are themselves Persistent objects are saved separately and track their own changes.

  • A plain mutable attribute (a list, dict or set) is not tracked: mutating it in place does not mark the owner as changed, so the change silently disappears.

The following example shows the trap and the three fixes, using two simultaneous connections to the same in-memory database so that the second one really reads what the first one committed:

import transaction
from persistent import Persistent
from persistent.list import PersistentList
from ZODB import DB


class Review(Persistent):
    def __init__(self, text):
        self.text = text
        self.tags = []                      # plain list: not tracked


db = DB(None)                               # None selects an in-memory storage
tm1 = transaction.TransactionManager()
tm2 = transaction.TransactionManager()
conn1, conn2 = db.open(tm1), db.open(tm2)

conn1.root()['r'] = Review('Great')
tm1.commit()
tm2.begin()

review = conn1.root()['r']
review.tags.append('classic')               # in-place change of a plain list
print('changed after append:', review._p_changed)
tm1.commit()
tm2.begin()
print('seen by the other connection:', conn2.root()['r'].tags)

review.tags = review.tags + ['sci-fi']      # fix 1: assign, so the attribute is set
print('changed after assignment:', review._p_changed)
tm1.commit()
tm2.begin()
print('seen by the other connection:', conn2.root()['r'].tags)

review.tags.append('epic')                  # fix 2: flag the owner by hand
review._p_changed = True
tm1.commit()
review.tags = PersistentList(review.tags)   # fix 3: use a persistent container from now on
review.tags.append('series')                # tracked on its own
tm1.commit()
tm2.begin()
print('final:', list(conn2.root()['r'].tags))

Running it prints:

changed after append: False
seen by the other connection: []
changed after assignment: True
seen by the other connection: ['classic', 'sci-fi']
final: ['classic', 'sci-fi', 'epic', 'series']

Note that the second line shows the empty list: the first commit stored nothing, and the later assignment happened to carry the earlier append along because the whole list was pickled again.

The building blocks that Pyramid applications use:

Type Use

persistent.Persistent

Base class of every stored object. Attributes starting with _p_ are reserved (_p_changed, _p_oid, _p_jar); attributes starting with _v_ are volatile, never stored and lost when the object leaves the connection cache.

persistent.mapping.PersistentMapping

A dict whose changes are tracked. All items live in one database record, so it suits small containers such as a shelf with a few dozen books.

persistent.list.PersistentList

A list whose changes are tracked, stored as one record.

BTrees.OOBTree.OOBTree, TreeSet

Scalable mappings and sets spread over many records; they can hold millions of items and merge concurrent changes. Keys must have a total ordering; str keys are always safe. Other families (IOBTree, LLBTree and so on) specialise the key and value types.

BTrees.Length.Length

A counter that merges concurrent changes by adding them. Use it next to a BTree to know its size without walking it.

ZODB.blob.Blob

Large binary data kept in files outside the main database file; needs a blob directory on the storage.

ZODB itself warns that records larger than connection_large_record_size (16 MB by default in zodburi) should be blobs. Pyramid’s ACLs and traversal need two more attributes on every resource, __name__ and __parent__. They are ordinary instance attributes here, stored with the object, so code that adds a child must set both (the helper add_child below does) and code that moves a child must update them.

pyramid_zodbconn

pyramid_zodbconn is the glue package. Including it with config.include('pyramid_zodbconn') reads the settings below at startup, opens one ZODB.DB per URI (stored on the registry and wrapped in an activity monitor) and makes get_connection(request) available.

Setting Meaning

zodbconn.uri

URI of the primary database (see the schemes below). If it is absent, get_connection raises ConfigurationError.

zodbconn.uri.<name>

A further, named database. A primary zodbconn.uri must also exist. Retrieved with get_connection(request, 'name').

zodbconn.transferlog

Path of a CSV file to which each request writes a line with timestamp, method, URL, elapsed seconds and the numbers of object loads and stores. An empty value writes to standard output.

zodbconn.transferlog_threshhold

Only log requests slower than this many seconds (an integer). The spelling with two h is the real setting name.

get_connection(request) opens a connection from the database’s pool the first time it is called in a request, caches it on the request, and registers a finished callback that aborts anything still pending, closes the connection (returning it to the pool) and fires the events listed below. It is deliberately not a tween, so it also works in scripts and pshell, where the router never runs. If pyramid_tm is active the connection joins request.tm; otherwise it uses the thread-local transaction manager and you must call transaction.commit() yourself.

The request lifecycle with ZODB, pyramid_tm and a root factory is:

sequenceDiagram participant C as Client participant TM as pyramid_tm tween participant RF as Root factory participant Z as ZODB connection pool participant V as View C->>TM: HTTP request TM->>TM: begin transaction, request.tm TM->>RF: root_factory(request) RF->>Z: get_connection(request) Z-->>RF: pooled connection joined to request.tm RF-->>TM: application root object TM->>V: traversal selects the view V->>V: change persistent objects V-->>TM: response or exception TM->>TM: commit, or abort on error or veto TM-->>C: response Note over Z: finished callback closes the connection and returns it to the pool
Figure 1. A request with pyramid_tm and pyramid_zodbconn

URI schemes

The URI is parsed by the zodburi package. Its documentation, and the pyramid_zodbconn documentation that quotes it, differ in age: the pyramid_zodbconn pages on the Pylons Project site still describe version 0.9.dev0 for Python 2.5 to 2.7, while the installed pyramid_zodbconn 0.8.1 (the latest release on PyPI, from 2017) works on Python 3 and zodburi 3.0.0 is current. Trust `zodburi’s documentation for the schemes.

Scheme Storage

file:///path/Data.fs

FileStorage: one append-only file, plus .index, .lock and .tmp files. Single process only.

memory://

In-memory MappingStorage. For tests, never for data you keep.

zeo://host:port

A ZEO client talking to a ZEO server, so many processes share one database.

zconfig:///path/zodb.conf#name

Anything ZODB can load through ZConfig, including RelStorage backends.

demo:(base_uri)/(changes_uri)

DemoStorage: a read-only base overlaid with a separate store for changes, handy for a development copy of production data.

Query-string options (all optional) that matter in practice:

Option Meaning

database_name

Name of this database in a multi-database set (the primary is registered under the empty name).

connection_cache_size

Target number of objects in each connection’s cache (default 10000 in zodburi; plain ZODB.DB defaults to 400).

connection_cache_size_bytes

The same limit in bytes, with KB, MB or GB suffixes. 0 means no limit.

connection_pool_size

Expected number of simultaneous connections (default 7). Exceeding it logs a warning rather than failing. Match it to your number of worker threads.

connection_pool_timeout

Seconds after which an idle pooled connection is discarded.

connection_large_record_size

Size above which ZODB warns that a blob would be better.

create, read_only, quota

FileStorage constructor arguments.

blobstorage_dir, blobstorage_layout

Wrap the file storage in a blob storage kept in this directory.

An unknown option raises UnknownDatabaseKeywords at startup, so typos fail fast. A zeo:// URI can also carry username and password options; never write them into a committed .ini file. Read them from the environment instead, as shown in the security section at the end of this page.

Events and the transfer log

Three events are sent through the registry: ZODBConnectionOpened, ZODBConnectionWillClose and ZODBConnectionClosed, each with conn and request attributes. The transferlog setting is built from them, and so can your own instrumentation; an example follows in the section on named databases below.

Bookshelf Shelves on ZODB

The scenario’s resource tree has the shape /shelves/<shelf>/<book>, where each shelf belongs to a user. With ZODB the tree is stored as it is: the library root holds a shelves container, which holds one container per shelf, which holds one Book object per book. The models:

import re

from persistent import Persistent
from persistent.list import PersistentList
from persistent.mapping import PersistentMapping
from pyramid.authorization import Allow, Everyone
from pyramid.config import Configurator
from pyramid.httpexceptions import HTTPBadRequest, HTTPConflict, HTTPSeeOther
from pyramid_zodbconn import get_connection


class Container(PersistentMapping):
    """A persistent, location-aware mapping: one node of the resource tree."""

    __name__ = None
    __parent__ = None


class Library(Container):
    pass


class Shelves(Container):
    pass


class Shelf(Container):
    def __init__(self, owner):
        super().__init__()
        self.owner = owner

    @property
    def __acl__(self):
        return [
            (Allow, Everyone, 'view'),
            (Allow, f'user:{self.owner}', ('add', 'edit')),
        ]


class Book(Persistent):
    __name__ = None
    __parent__ = None

    def __init__(self, title, author):
        self.title = title
        self.author = author
        self.tags = PersistentList()

    def __json__(self, request):
        return {'title': self.title, 'author': self.author,
                'tags': list(self.tags), 'url': request.resource_url(self)}


def add_child(container, name, child):
    child.__name__ = name
    child.__parent__ = container
    container[name] = child
    return child


def appmaker(zodb_root):
    """Create the application root on first use, as the wiki tutorial does."""
    if 'library' not in zodb_root:
        library = Library()
        add_child(library, 'shelves', Shelves())
        zodb_root['library'] = library
    return zodb_root['library']


def root_factory(request):
    return appmaker(get_connection(request).root())

A few points about this design. Shelf.__acl__ is a property computed from the stored owner, so the permissions are derived from persistent state and no ACL list has to be kept in sync; Pyramid reads __acl__ from each object in the lineage, as covered in the authorization page of this section. The stored __parent__ links make the tree bidirectional, which request.resource_url and find_resource rely on. appmaker runs on every request, but only writes the first time; with pyramid_tm active that first write is committed at the end of whichever request came first, even a GET.

The views, the configuration and a test client follow. Two details are worth reading closely. Names that arrive in the request are validated and checked against a reserved list, because a shelf called add_book would shadow the view of that name when traversal reaches the shelf. And the owner is read from the form only to keep the example short; a real application derives it from the authenticated identity (see the security pages of this section).

NAME = re.compile(r'[a-z0-9-]{1,40}\Z')
RESERVED = {'add_shelf', 'add_book', 'read'}


def clean_name(request, key):
    name = request.POST.get(key, '')
    if not NAME.match(name) or name in RESERVED:
        raise HTTPBadRequest(f'invalid {key}')
    return name


def library_view(context, request):
    return {'shelves': request.resource_url(context['shelves'])}


def shelves_view(context, request):
    return {'shelves': sorted(context)}


def add_shelf(context, request):
    name = clean_name(request, 'name')
    if name in context:
        raise HTTPConflict('shelf exists')
    shelf = add_child(context, name, Shelf(owner=clean_name(request, 'owner')))
    return HTTPSeeOther(request.resource_url(shelf))


def shelf_view(context, request):
    return {'owner': context.owner, 'books': sorted(context)}


def add_book(context, request):
    name = clean_name(request, 'name')
    if name in context:
        raise HTTPConflict('book exists')
    book = add_child(context, name,
                     Book(request.POST['title'], request.POST['author']))
    return HTTPSeeOther(request.resource_url(book))


def book_view(context, request):
    return context


def make_app(settings, extra=None):
    with Configurator(settings=settings) as config:
        config.include('pyramid_tm')
        config.include('pyramid_retry')
        config.include('pyramid_zodbconn')
        config.set_root_factory(root_factory)
        config.add_view(library_view, context=Library, renderer='json')
        config.add_view(shelves_view, context=Shelves, renderer='json')
        config.add_view(add_shelf, context=Shelves, name='add_shelf',
                        request_method='POST')
        config.add_view(shelf_view, context=Shelf, renderer='json')
        config.add_view(add_book, context=Shelf, name='add_book',
                        request_method='POST')
        config.add_view(book_view, context=Book, renderer='json')
        if extra:
            config.include(extra)
    return config.make_wsgi_app()
from webtest import TestApp

app = make_app({'zodbconn.uri': 'memory://', 'retry.attempts': '3'})
client = TestApp(app)

print(client.post('/shelves/add_shelf', {'name': 'ada', 'owner': 'ada'}).location)
print(client.post('/shelves/ada/add_book', {'name': 'dune', 'title': 'Dune',
                                           'author': 'Frank Herbert'}).location)
print(client.get('/shelves/ada/dune').json)
print(client.get('/shelves/ada').json)
print(client.post('/shelves/add_shelf', {'name': 'add_shelf', 'owner': 'x'},
                  status=400).status)
print(client.post('/shelves/add_shelf', {'name': 'ada', 'owner': 'x'},
                  status=409).status)
client.get('/shelves/nobody', status=404)

Output:

http://localhost/shelves/ada/
http://localhost/shelves/ada/dune/
{'title': 'Dune', 'author': 'Frank Herbert', 'tags': [], 'url': 'http://localhost/shelves/ada/dune/'}
{'owner': 'ada', 'books': ['dune']}
400 Bad Request
409 Conflict

Each request ran in its own transaction on its own pooled connection: the second request found the shelf created by the first because the commit made it visible. The test used memory://; against a file the only change is the URI.

Because get_connection is not a tween, the same root is reachable from a script. prepare() builds a request and runs the root factory, so the persisted ACLs can be checked without HTTP. ACLHelper().permits(context, principals, permission) is the 2.1 API that evaluates an ACL, and prepare() is also what pshell uses:

from pyramid.authorization import ACLHelper
from pyramid.scripting import prepare

with prepare(registry=app.registry) as env:
    shelf = env['root']['shelves']['ada']
    helper = ACLHelper()
    print(bool(helper.permits(shelf, ['user:ada'], 'edit')))
    print(bool(helper.permits(shelf, ['user:bob'], 'edit')))
    print(bool(helper.permits(shelf['dune'], [Everyone, 'user:bob'], 'view')))
    print(shelf['dune'].__parent__ is shelf)

It prints True, False, True and True: the owner may edit, another user may not, everybody may view a book because the shelf’s ACL is inherited down the lineage, and the stored __parent__ link points back to the shelf.

Transactions, Retries and Conflicts

ZODB uses optimistic concurrency with MVCC. Each connection reads a consistent snapshot, writers do not block readers and nothing is locked while a request runs. At commit time the storage compares the revision of every object the transaction wrote with the revision it started from. If another transaction committed a newer revision of the same object in the meantime, the commit fails with ZODB.POSException.ConflictError, unless the class can merge the two changes itself (see conflict resolution below). That is why ZODB applications must expect to retry.

pyramid_tm

pyramid_tm starts a transaction at the beginning of the request (request.tm.begin()), and at the end it aborts if the transaction was doomed, an exception escaped the view, or the optional commit veto asked for it; otherwise it commits. Its settings:

Setting Meaning

tm.commit_veto

Dotted name of a callable (request, response) returning true to abort. pyramid_tm.default_commit_veto vetoes 4xx and 5xx responses and honours an X-Tm response header.

tm.manager_hook

Dotted name of a callable returning the transaction manager for a request. pyramid_tm.explicit_manager returns an explicit-mode manager and is recommended over the thread-local default.

tm.activate_hook

Dotted name of a callable deciding per request whether the tween is active.

tm.annotate_user

Set to false to stop the tween reading the authenticated user id to annotate the transaction, which matters if your security policy would itself need the database.

Two traps follow from the tween order. pyramid_tm sits above the exception-view tween, so the transaction may still be open while an exception view runs, but this is not guaranteed; a view that needs it declares tm_active=True. And work done in the failure path (audit logging, for instance) must use a separate transaction, because everything added to request.tm is aborted on failure. A manual request.tm.commit() or abort() inside a view is legal but ends the request’s transaction early; with the explicit-mode manager you must call request.tm.begin() again before further use.

pyramid_retry

Transient errors are handled by the pyramid_retry execution policy. Include it, set retry.attempts to a value above one (the cookiecutter uses 3) and the request is run again, with new request state, when it raises a retryable exception. pyramid_tm marks ZODB.POSException.ConflictError and anything deriving from transaction.interfaces.TransientError as retryable; your own exceptions can opt in with pyramid_retry.mark_error_retryable or by deriving from pyramid_retry.RetryableException, and last_retry_attempt is a view predicate that is true only on the final attempt. The example raises a conflict on the first attempt only:

from ZODB.POSException import ConflictError

attempts = []


def flaky(request):
    attempts.append(1)
    if len(attempts) == 1:
        raise ConflictError()
    return {'attempts': len(attempts)}


with Configurator(settings={'retry.attempts': '3'}) as config:
    config.include('pyramid_tm')
    config.include('pyramid_retry')
    config.add_route('flaky', '/flaky')
    config.add_view(flaky, route_name='flaky', renderer='json')
    retrying_app = config.make_wsgi_app()

print(TestApp(retrying_app).get('/flaky').json)

It prints {'attempts': 2}. Because the whole request is executed again, views must be safe to repeat: do not send an email or call a payment API inside the transaction before it has committed (the Redis and MongoDB section below shows how to defer such effects until after the commit).

Source: pyramid_tm

Conflicts, BTrees and counters

Which data structure you pick decides how often conflicts happen. A PersistentMapping is a single record, so two requests adding different keys to the same shelf conflict. An OOBTree splits its keys over buckets, and ZODB merges changes made to different keys of a bucket. Length merges counter updates by adding them. The experiment uses FileStorage, because the conflict-resolution machinery is exercised by it; two connections with separate transaction managers stand in for two concurrent requests:

import os
import tempfile

from BTrees.Length import Length
from BTrees.OOBTree import OOBTree

tmp = tempfile.TemporaryDirectory()
db = DB(os.path.join(tmp.name, 'Data.fs'))
tm1, tm2 = transaction.TransactionManager(), transaction.TransactionManager()
c1, c2 = db.open(tm1), db.open(tm2)
root = c1.root()
root['pm'] = PersistentMapping({f'k{i:04}': i for i in range(300)})
root['bt'] = OOBTree({f'k{i:04}': i for i in range(300)})
root['n'] = Length(300)
tm1.commit()
tm2.begin()


def race(label, change1, change2):
    tm1.begin()
    tm2.begin()
    change1(c1.root())
    change2(c2.root())
    tm1.commit()
    try:
        tm2.commit()
        print(label, 'merged')
    except ConflictError:
        tm2.abort()
        print(label, 'ConflictError')


race('PersistentMapping', lambda r: r['pm'].update(a=1), lambda r: r['pm'].update(z=2))
race('OOBTree', lambda r: r['bt'].update({'a': 1}), lambda r: r['bt'].update({'z': 2}))
race('Length', lambda r: r['n'].change(1), lambda r: r['n'].change(1))
race('same attribute', lambda r: setattr(r['pm'], 'n', 1), lambda r: setattr(r['pm'], 'n', 2))
db.close()
PersistentMapping ConflictError
OOBTree merged
Length merged
same attribute ConflictError

Practical consequences for the Bookshelf Shelves design: keep per-shelf containers small, or make containers that can grow large (a shelf with thousands of books, a shelves container shared by every user) OOBTree subclasses, which keep the mapping protocol that traversal needs; count with Length, never with an integer attribute updated by every request; and do not write to a hot object (a visit counter on the root, a "last seen" timestamp on a shared object) from every read request. ZODB warns that conflict resolution weakens consistency and advises against writing your own _p_resolveConflict. In a quick test a BTree small enough to live in a single bucket did not merge either, and conflicts can still occur when a bucket splits, so do not rely on merging for small structures.

Savepoints

Not every error should abort the whole request. request.tm.savepoint() creates a savepoint across all data managers joined to the transaction (ZODB and SQLAlchemy alike), and savepoint.rollback() undoes only the work after it. Use this instead of a storage-specific facility such as the SQLAlchemy session’s own nested transactions. Not every data manager supports savepoints, so changes in one that does not cannot be rolled back this way.

Named Databases, Events and the Transfer Log

The following example wires an event subscriber, the transfer log and a second, named database, and reads both connections from a script:

import pyramid_zodbconn as pz

seen = []


def on_closing(event):
    seen.append((event.request.path, event.conn.getTransferCounts()))


logdir = tempfile.TemporaryDirectory()
logfile = os.path.join(logdir.name, 'transfers.csv')
settings = {
    'zodbconn.uri': 'memory://',
    'zodbconn.uri.sessions': 'memory://?database_name=sessions',
    'zodbconn.transferlog': logfile,
}
logged_app = make_app(settings, extra=lambda c: c.add_subscriber(on_closing, pz.ZODBConnectionWillClose))
TestApp(logged_app).get('/')
print(seen)
print(open(logfile).read().strip().split(', ')[1:2])

with prepare(registry=logged_app.registry) as env:
    main_conn = pz.get_connection(env['request'])
    sessions_conn = pz.get_connection(env['request'], 'sessions')
    print(repr(main_conn.db().database_name), repr(sessions_conn.db().database_name))

The subscriber saw one request that loaded no objects and stored four (the application root being created); the log line contains the quoted method and path; the primary database is registered under the empty name and the second one under sessions. Use the transfer log to find requests that load thousands of objects, which usually means a cache that is too small (connection_cache_size) or a view that walks a whole container.

Operating a ZODB Database

Packing and the files on disk

ZODB never overwrites an object: every commit appends a new revision, so a FileStorage file only grows. Packing removes revisions that are no longer reachable. FileStorage keeps a copy of the pre-pack file with the suffix .old unless it was opened with pack_keep_old=False, so a pack temporarily needs room for a second copy.

class Counter(Persistent):
    value = 0


workdir = tempfile.TemporaryDirectory()
path = os.path.join(workdir.name, 'Data.fs')
db = DB(path)
with db.transaction() as conn:
    conn.root()['c'] = Counter()
for i in range(300):
    with db.transaction() as conn:
        conn.root()['c'].value = i
print(sorted(os.listdir(workdir.name)), os.path.getsize(path) > 10000)
db.pack()
print(sorted(os.listdir(workdir.name)), os.path.getsize(path) < 1000)

try:
    DB(path)                                # a second process would hit the lock
except Exception as exc:
    print(type(exc).__name__)
db.close()
['Data.fs', 'Data.fs.index', 'Data.fs.lock', 'Data.fs.tmp'] True
['Data.fs', 'Data.fs.index', 'Data.fs.lock', 'Data.fs.old', 'Data.fs.tmp'] True
LockError

DB.pack(t=None, days=0) packs to the current time by default; days=7 keeps a week of history. Pack off-peak, because it is expensive, and schedule it (cron or a command-line script that opens the application, see the later command-line page). Data.fs.index is a cache that speeds up startup, Data.fs.lock guards against a second process, and Data.fs.tmp is a working file. The LockError shows the most important operational fact about FileStorage: only one process can open the file. A server that forks several worker processes (Gunicorn with more than one worker, uWSGI with processes) cannot share it. Waitress runs one process with a thread pool, which is fine, so size connection_pool_size to the thread count. For several processes, put the database behind ZEO (zeo:// URI), or use RelStorage or NEO through zconfig://, as the ZODB guide recommends.

Backups

Do not copy Data.fs while the application is running. ZODB ships repozo, which makes incremental and full backups of a live file into a repository directory (repozo -B -r <backup dir> -f <Data.fs> to back up, -R to recover, -V to verify, -F to force a full backup). Run it from cron and keep the repository on another machine. After a pack, the next backup has to be a full one. With ZEO or RelStorage use the server’s or the backing database’s own tools.

Changing classes

ZODB stores the name of each object’s class in its pickle. Renaming or moving a class (or its module) breaks loading of every instance with an import or attribute error until the old name is importable again. Rules that keep a long-lived database healthy: keep old class names importable (an alias in the old module); add attributes with a class-level default so old instances load; for real data migrations, write a script that opens the application, walks the tree and fixes objects inside a transaction; and never delete the module of a class you may still have stored. The wiki tutorial’s first step makes the same point: after replacing MyModel, delete the old Data.fs or the application fails to start.

Testing against ZODB

The cookiecutter conftest.py creates the application once per session and builds testapp with extra_environ={'tm.active': True, 'tm.manager': tm}, where tm is a doomed transaction. tm.active turns pyramid_tm off for the request and tm.manager replaces request.tm, so the test controls the transaction. pyramid_zodbconn closes its connection at the end of each request and aborts the transaction when it does, so every request in such a test is rolled back, including its writes. The example makes that visible:

tm = transaction.manager
tm.begin()
tm.doom()
isolated = TestApp(app, extra_environ={'tm.active': True, 'tm.manager': tm})
print(isolated.post('/shelves/add_shelf', {'name': 'bob', 'owner': 'bob'}).status)
print(isolated.get('/shelves').json)
tm.abort()

The POST answers 303 See Other, yet the GET lists no bob. That is excellent isolation for read-only tests and for a test that checks a single response, but it cannot test a flow that spans several requests. For those, build a fresh application with zodbconn.uri = memory:// per test (a function-scoped fixture, as make_app above does) and leave pyramid_tm active, so each request commits for real and the database vanishes with the test. Unit-test models with no Pyramid at all by calling appmaker({}) on a plain dict, as the wiki tutorial’s test_appmaker does, and views with pyramid.testing.DummyResource and DummyRequest.

Redis and MongoDB

Redis and MongoDB are not transactional resources of the transaction package, and Pyramid ships no integration for either. Pyramid’s contribution is only a place to put the client and a way to scope it to the request:

  • One client per process, created at startup in an includeme function and kept on the registry. redis.Redis holds a connection pool, and pymongo.MongoClient is thread-safe and is meant to be created once and shared, so neither should be built per request.

  • A request method added with config.add_request_method(callable, name, reify=True) that exposes the shared client (or a per-request object derived from it) as request.redis or request.mongo_db. reify=True computes it once per request.

  • Anything that must be released at the end of the request is released in a callback registered with request.add_finished_callback, which runs whether the request succeeded or raised.

  • Connection URLs come from settings, which come from the environment (see the page on settings and .ini files: Settings, Environment and .ini Files), never from literals that contain passwords.

The hard part is consistency. A ZODB commit can be rolled back and a Redis INCR cannot, so a view that increments a counter and then fails (or is retried by pyramid_retry) would count twice or count a rolled-back change. The pattern below defers every non-transactional side effect until after a successful commit, using the transaction’s addAfterCommitHook, and resets the per-request Redis pipeline in a finished callback. The Redis pipeline is the per-request resource; MongoDB’s insert is queued the same way. An explicit trade-off remains: if the process dies between the commit and the hook, the side effect is lost, so make such effects idempotent or rebuildable, and use an outbox or a queue (see Messaging) where loss is not acceptable.

import pymongo
import redis
from bson import ObjectId
from pyramid.renderers import JSON


def includeme(config):
    settings = config.get_settings()
    registry = config.registry
    registry.redis = redis.Redis.from_url(settings['redis.url'], decode_responses=True)
    registry.mongo = pymongo.MongoClient(settings['mongo.url'])
    db_name = settings.get('mongo.db', 'bookshelf')

    config.add_request_method(lambda request: request.registry.redis, 'redis', reify=True)
    config.add_request_method(lambda request: request.registry.mongo[db_name], 'mongo_db', reify=True)
    config.add_request_method(after_commit, 'after_commit')
    config.add_request_method(redis_pipeline, 'redis_pipeline', reify=True)

    json_renderer = JSON()
    json_renderer.add_adapter(ObjectId, lambda value, request: str(value))
    config.add_renderer('json', json_renderer)
    config.add_view(read_book, context=Book, name='read', request_method='POST', renderer='json')
    config.add_view(read_and_fail, context=Book, name='read_fail', request_method='POST')
    config.add_view(events_view, context=Shelf, name='events', renderer='json')


def after_commit(request, func):
    """Run func only once the ZODB transaction has committed."""
    def hook(success):
        if success:
            func()
    request.tm.get().addAfterCommitHook(hook)


def redis_pipeline(request):
    pipe = request.redis.pipeline()
    request.add_finished_callback(lambda req: pipe.reset())
    request.after_commit(pipe.execute)
    return pipe


def read_book(context, request):
    key = f'reads:{context.__parent__.__name__}:{context.__name__}'
    request.redis_pipeline.incr(key)
    request.after_commit(lambda: request.mongo_db.events.insert_one(
        {'type': 'read', 'shelf': context.__parent__.__name__, 'book': context.__name__}))
    context.last_read = 'today'             # the transactional part, stored in ZODB
    return {'queued': key}


def read_and_fail(context, request):
    request.redis_pipeline.incr('reads:never')
    raise ValueError('boom')


def events_view(context, request):
    return list(request.mongo_db.events.find({'shelf': context.__name__}))

The test patches the two client constructors with the in-process fakes, so no server is needed. In production the settings would carry redis.url and mongo.url read from the environment:

from unittest import mock

import fakeredis
import mongomock

with mock.patch('redis.Redis.from_url', fakeredis.FakeRedis.from_url), \
        mock.patch('pymongo.MongoClient', mongomock.MongoClient):
    stores_app = make_app({'zodbconn.uri': 'memory://',
                           'redis.url': 'redis://localhost:6379/0',
                           'mongo.url': 'mongodb://localhost:27017'}, extra=includeme)
stores = TestApp(stores_app)
stores.post('/shelves/add_shelf', {'name': 'ada', 'owner': 'ada'})
stores.post('/shelves/ada/add_book', {'name': 'dune', 'title': 'Dune', 'author': 'Frank Herbert'})
print(stores.post('/shelves/ada/dune/read').json)
stores.post('/shelves/ada/dune/read')
try:
    stores.post('/shelves/ada/dune/read_fail')
except ValueError as exc:
    print('raised', exc)
print(stores_app.registry.redis.get('reads:ada:dune'), stores_app.registry.redis.get('reads:never'))
print([e['type'] for e in stores.get('/shelves/ada/events').json])
{'queued': 'reads:ada:dune'}
raised boom
2 None
['read', 'read']

Two successful reads were counted and logged; the failing view queued an increment, but because the transaction aborted the hook never ran and reads:never does not exist. The ObjectId adapter on the JSON renderer (see Renderers and JSON) turns MongoDB’s _id values into strings, which the default renderer cannot serialise.

MongoDB operator injection

A MongoDB filter is a dictionary, and a JSON request body can put a dictionary where you expected a string. Passing it straight into find lets a client send {"$ne": null} and match every document. Validate the type before building the filter:

events = stores_app.registry.mongo['bookshelf'].events


def find_events_unsafe(shelf):
    return list(events.find({'shelf': shelf}))


def find_events(shelf):
    if not isinstance(shelf, str):
        raise HTTPBadRequest('shelf must be a string')
    return list(events.find({'shelf': shelf}))


print(len(find_events_unsafe({'$ne': None})))      # every document matches
try:
    find_events({'$ne': None})
except HTTPBadRequest as exc:
    print('rejected:', exc)

The unsafe call returns all documents and the safe one raises HTTPBadRequest. Query-string and form values are always strings in Pyramid (request.params, request.POST), so the risk concentrates in request.json_body. Validate JSON with a schema library as covered in the forms page of this section, and see the OWASP injection and NoSQL security cheat sheets (linked in the references) for the wider picture.

Redis specifics

Keep keys namespaced (reads:<shelf>:<book>) so that several applications can share an instance, set expirations on anything that is a cache, and remember that decode_responses=True makes the client return str instead of bytes. pipeline() defaults to a transactional MULTI/EXEC block; its execute() sends everything at once, and reset() discards queued commands and releases the connection if the pipeline was never executed, which is what the finished callback does. For Redis as a Pyramid session store or as the cache for rendered fragments, see the sessions page of this section; for the engine itself, see Redis and, for the Python client in other frameworks, Flask: NoSQL (Redis, MongoDB and Search) and Tornado: Databases and Async Drivers. Remember that Pyramid is synchronous WSGI in 2.1, so these blocking clients are correct here, unlike in an async framework such as FastAPI where an async client is needed.

Security

  • Never hardcode connection details. Build the URI from the environment when the application starts. The example keeps a safe local default and lets the deployment override it, so a zeo:// URI with username and password options, or a redis:// URL with a password, never appears in a file under version control:

    def main(global_config, **settings):
        settings['zodbconn.uri'] = os.environ.get('BOOKSHELF_ZODB_URI', settings.get('zodbconn.uri', 'memory://'))
        return make_app(settings)
    
    
    os.environ['BOOKSHELF_ZODB_URI'] = 'memory://'
    print(main({}).registry.settings['zodbconn.uri'])
  • ZODB stores pickles. Loading a pickle that an attacker wrote can execute arbitrary code, so never store raw pickles, Persistent instances or class names taken from user input, never restore a Data.fs of unknown origin, and keep the file readable only by the application user. Pyramid 2.0 made the same point for sessions by dropping pickle from SignedCookieSessionFactory.

  • A ZEO server is a network service. Bind it to a private interface and firewall it; ZEO documents an SSL option for its connections, and its protocol description is titled "sans authentication", so treat network access as the credential: anyone who can reach an open ZEO port can read and write the whole database.

  • Validate every name that becomes a key, as clean_name does: keys are URL segments and are looked up by __getitem__, so reserved view names and path tricks must be rejected before they are stored.

  • Authorization lives in the tree. Because __acl__ is read from the object and its ancestors, a mistake in __parent__ links silently changes who may do what. Test the lineage, as the script above did.

  • Treat Redis and MongoDB like databases: no public ports, authentication on, TLS where the network is shared, and the least-privileged account for the application. The OWASP cheat sheets for injection prevention and NoSQL security (linked below) apply to the Mongo filters shown above.

Recent Changes and Outdated Material

Item What to know

pyramid_zodbconn documentation

The Pylons Project pages describe 0.9.dev0 for Python 2.5 to 2.7; the package on PyPI is 0.8.1 (2017) and ran without problems on Python 3.13 with Pyramid 2.1. Its only 0.8 changes of note are opening the primary database with request.tm when present and not aborting when no transaction is active.

zodburi

3.0.0 resolves file, zeo, zconfig, memory and demo and takes all connection_* options; it imports ZEO, so installing pyramid_zodbconn also installs ZEO. The demostorage query option is deprecated in favour of the demo: scheme.

pyramid_tm 2.x

Retries moved out to pyramid_retry (retry.attempts); older tutorials show tm.attempts. tm.manager_hook and pyramid_tm.explicit_manager are the supported way to avoid the thread-local manager.

Tutorials with setup.py

Wiki tutorials from before 2.1 install with python setup.py develop; use pip install -e ".[testing]" with the pyproject.toml the cookiecutter generates.

Authorization in the wiki tutorial

Older tutorials configure AuthTktAuthenticationPolicy and ACLAuthorizationPolicy; these are the legacy, deprecated policies. Pyramid 2.0 and later use a single security policy (config.set_security_policy) and ACLHelper; the ACL constants live in pyramid.authorization.

Python versions

Pyramid 2.1 supports Python 3.10 and newer, although the generated pyproject.toml still says 3.8; Pyramid 2.1 itself still imports pkg_resources and needs setuptools older than 82.

Summary

  • ZODB stores Python objects reachable from a root mapping; with traversal the object tree is the URL space, and the cookiecutter zodb backend and the wiki tutorial set it up with pyramid_zodbconn, pyramid_tm and pyramid_retry.

  • Track changes by using immutable values, Persistent children or PersistentList/PersistentMapping, or set _p_changed; an in-place change of a plain list or dict is lost.

  • zodbconn.uri (a zodburi URI) opens the database, get_connection(request) opens a pooled per-request connection that is closed by a finished callback, and named databases use zodbconn.uri.<name>.

  • Optimistic concurrency means ConflictError; retry with retry.attempts, use OOBTree and Length for hot structures, and keep views repeatable.

  • FileStorage is single process, grows until packed (DB.pack, .old copy), is backed up with repozo, and stores class names, so renames need care; use ZEO or RelStorage for several processes.

  • Redis and MongoDB clients are created once per process, exposed with add_request_method, released with finished callbacks, and every non-transactional side effect is deferred with an after-commit hook.

  • Keep credentials in the environment, never unpickle untrusted data, protect ZEO, and validate the type of every value that reaches a MongoDB filter.