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 |
|
|
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 |
Same package via |
Scaling out |
|
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__.pyincludespyramid_tm,pyramid_retryandpyramid_zodbconn, and sets a root factory that opens a ZODB connection withget_connection(request)and returns the application root. -
models/__init__.pyholds aMyModelclass (aPersistentMappingthat is its own root) andappmaker(), which creates the root object the first time the database is empty. -
The
.inifiles carryzodbconn.uriandretry.attempts, and a[pshell]section whose setup hook starts a transaction for interactive sessions. -
tests/conftest.pybuilds the application fromtesting.ini, with atmfixture and atestappfixture 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.
Source: Wiki Tutorial: Basic Layout
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
Persistentobjects are saved separately and track their own changes. -
A plain mutable attribute (a
list,dictorset) 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.
Source: ZODB: Writing persistent objects
The building blocks that Pyramid applications use:
| Type | Use |
|---|---|
|
Base class of every stored object. Attributes starting with |
|
A |
|
A |
|
Scalable mappings and sets spread over many records; they can hold millions of items and merge concurrent changes. Keys must have a total ordering; |
|
A counter that merges concurrent changes by adding them. Use it next to a BTree to know its size without walking it. |
|
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 |
|---|---|
|
URI of the primary database (see the schemes below). If it is absent, |
|
A further, named database. A primary |
|
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. |
|
Only log requests slower than this many seconds (an integer). The spelling with two |
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:
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 |
|---|---|
|
|
|
In-memory |
|
A ZEO client talking to a ZEO server, so many processes share one database. |
|
Anything ZODB can load through ZConfig, including RelStorage backends. |
|
|
Query-string options (all optional) that matter in practice:
| Option | Meaning |
|---|---|
|
Name of this database in a multi-database set (the primary is registered under the empty name). |
|
Target number of objects in each connection’s cache (default 10000 in |
|
The same limit in bytes, with |
|
Expected number of simultaneous connections (default 7). Exceeding it logs a warning rather than failing. Match it to your number of worker threads. |
|
Seconds after which an idle pooled connection is discarded. |
|
Size above which ZODB warns that a blob would be better. |
|
|
|
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.
Source: Wiki Tutorial: Basic Layout
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.
Source: pyramid.authorization
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 |
|---|---|
|
Dotted name of a callable |
|
Dotted name of a callable returning the transaction manager for a request. |
|
Dotted name of a callable deciding per request whether the tween is active. |
|
Set to |
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.
Source: pyramid_tm: Savepoints
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.
Source: pyramid_zodbconn
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()
Source: ZODB: DB.pack and ZODB: Storages
['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.
Source: Wiki Tutorial: Adding Tests
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
includemefunction and kept on the registry.redis.Redisholds a connection pool, andpymongo.MongoClientis 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) asrequest.redisorrequest.mongo_db.reify=Truecomputes 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
.inifiles: 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__}))
Source: Configurator.add_request_method
Source: Request.add_finished_callback
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.
Source: Pyramid: JSON Renderer
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.
Source: PyMongo documentation
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 withusernameandpasswordoptions, or aredis://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,
Persistentinstances or class names taken from user input, never restore aData.fsof unknown origin, and keep the file readable only by the application user. Pyramid 2.0 made the same point for sessions by dropping pickle fromSignedCookieSessionFactory. -
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_namedoes: 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 |
|---|---|
|
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 |
|
3.0.0 resolves |
|
Retries moved out to |
Tutorials with |
Wiki tutorials from before 2.1 install with |
Authorization in the wiki tutorial |
Older tutorials configure |
Python versions |
Pyramid 2.1 supports Python 3.10 and newer, although the generated |
Summary
-
ZODB stores Python objects reachable from a root mapping; with traversal the object tree is the URL space, and the cookiecutter
zodbbackend and the wiki tutorial set it up withpyramid_zodbconn,pyramid_tmandpyramid_retry. -
Track changes by using immutable values,
Persistentchildren orPersistentList/PersistentMapping, or set_p_changed; an in-place change of a plain list or dict is lost. -
zodbconn.uri(azodburiURI) opens the database,get_connection(request)opens a pooled per-request connection that is closed by a finished callback, and named databases usezodbconn.uri.<name>. -
Optimistic concurrency means
ConflictError; retry withretry.attempts, useOOBTreeandLengthfor hot structures, and keep views repeatable. -
FileStorageis single process, grows until packed (DB.pack,.oldcopy), is backed up withrepozo, 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.