Transactions & Streaming¶
Two problems the ORM handles at the connection level, below the query builder: keeping a group of
writes all-or-nothing, and reading a large result set without loading it all into memory at
once. Both go through the db connection manager (app("db"), or the DB facade), so they
compose with everything the builder does — a model save inside a transaction rolls back with it, a
streamed query honors your where/order clauses.
The examples below use a db handle — resolve it with db = app("db"), take it as a
constructor dependency, or reach for the DB facade (DB.transaction()), whichever fits the layer
you're in.
Transactions¶
Wrap a unit of work in db.transaction() and every write inside it commits together or not at all —
one failure rolls the whole block back:
async with db.transaction():
account = await Account.find(sender_id)
account.balance -= amount
await account.save()
await Account.where(id=recipient_id).increment("balance", amount)
await Transfer.create(sender_id=sender_id, recipient_id=recipient_id, amount=amount)
# commit here, on a clean exit
If any statement raises — a constraint violation, a bad lookup, your own abort() — the block exits
without committing and the database is left exactly as it was. There's no try/except to remember and
no manual rollback() to call: leaving the async with normally commits, leaving it via an exception
rolls back.
Nesting: savepoints, not a second BEGIN¶
Transactions nest. Opening db.transaction() while one is already active doesn't start a second
top-level transaction (databases don't have those) — it opens a savepoint, so an inner failure
rolls back only the inner block:
async with db.transaction(): # outer
await Order.create(...)
try:
async with db.transaction(): # savepoint
await charge_card(order) # if this raises...
except PaymentError:
order.status = "unpaid" # ...only the inner block rolled back;
await order.save() # the order still exists, and this commits with the outer
The outermost transaction is the one that actually commits. It also owns the after-commit buffer:
events dispatched with after_commit and jobs queued inside the block are held until the real
COMMIT lands, and dropped entirely if the transaction rolls back — so you never fire an "order
placed" event for an order that didn't persist. See Events for after_commit dispatch.
The decorator and callback forms¶
Two shorthands wrap the same machinery. @db.transactional runs a whole function in a transaction:
@db.transactional
async def place_order(cart):
order = await Order.create(customer_id=cart.customer_id, total=cart.total)
for line in cart.lines:
await order.items().create(product_id=line.product_id, qty=line.qty)
return order
db.transact(callback) is the callback form — useful when you want the connection in hand, or when
you want automatic retries on a deadlock:
await db.transact(transfer_funds) # commit on success, rollback on error
await db.transact(transfer_funds, attempts=3) # retry a deadlock/serialization failure
attempts retries only transient failures — a deadlock or serialization conflict, the kind that
succeeds if you just run it again — with a capped exponential backoff between tries. A permanent
error (a unique-constraint violation, a type error, bad SQL) raises immediately without burning a
retry, because re-running it would fail identically. The default is a single attempt.
Pessimistic locking¶
When two requests read-then-write the same row, the last write can clobber the first. Take a row-level lock inside a transaction to serialize them:
async with db.transaction():
seat = await Seat.where(id=seat_id).lock_for_update().first() # FOR UPDATE
if seat.taken:
abort(409, "Seat already booked.")
seat.taken = True
await seat.save()
lock_for_update()—SELECT … FOR UPDATE. Other transactions block on this row until yours commits. Use it when you'll write the row you read.shared_lock()—SELECT … FOR SHARE. Others can still read but can't write until you commit. Use it when you need the row to stay put while you read related data, but aren't changing it.
Locks only mean anything inside a transaction — a lock on an autocommitted query is released the
instant the statement finishes. SQLite has no row-level lock (it locks the whole database for a
write), so these compile to a plain SELECT there; the transaction still serializes writers.
Raw SQL¶
The builder covers the common cases; when you need SQL it doesn't express, drop down without leaving the connection (and its transaction, logging, and telemetry):
rows = await db.select(
"SELECT id, name FROM users WHERE last_login < :cutoff",
{"cutoff": cutoff},
) # list of dict-like rows
await db.statement("REFRESH MATERIALIZED VIEW monthly_revenue") # INSERT/UPDATE/DDL
db.select returns mapped rows (dict-like); db.statement runs a write or DDL statement and returns
the driver result. Always pass values as bind parameters (:name) — never format them into the
string — so the driver escapes them and you're safe from SQL injection. Both run on the active
transaction's connection when one is open (so they see your uncommitted writes and roll back with the
block); standalone, db.statement commits itself and marks the connection sticky, so a subsequent
read on the same request routes to the primary, not a replica.
Streaming large result sets¶
await Query.get() materializes every matching row into a list. For a table you know is large — an
export, a backfill, a nightly reconciliation — that's a memory spike waiting to happen. Stream instead.
Iterate one model at a time¶
cursor() (and its alias lazy()) opens a server-side cursor and yields hydrated models one by one —
constant memory no matter how many rows match:
Process in batches¶
When per-row work is cheap but you want to batch a side effect (a bulk API call, a WHERE IN write),
page through in chunks. There are two strategies, and the difference matters:
# keyset pagination on the primary key — the safe default
await User.where(active=True).chunk_by_id(500, handle_batch)
# offset pagination — simpler, but rows shifting between pages get skipped/repeated
await User.where(active=True).chunk(500, handle_batch)
# per-row, streamed chunk_size at a time under the hood
await User.where(active=True).each(mark_seen, chunk_size=1000)
Prefer chunk_by_id. It walks the table by WHERE id > <last> instead of LIMIT/OFFSET, so it's
stable even when rows are inserted or deleted mid-walk — an offset-based chunk() shifts every later
page when a row is added, silently skipping or repeating rows. Reach for chunk() only on a read-only
snapshot you know isn't changing. All three accept a sync or async callback, and returning False from
it stops the walk early:
async def handle_batch(users):
await mailer.send_bulk(users)
if quota_exceeded():
return False # stop paging
chunk_by_id keys off the model's primary key by default; pass column= to page by a different
ordered column. Because it uses keyset pagination, it works for string and UUID primary keys, not just
integers.
Common mistakes & gotchas¶
- Locking outside a transaction does nothing.
lock_for_update()on a bare query releases the lock as soon as the statement returns. Wrap the read and the write in onedb.transaction(). - Retrying a permanent error.
attempts=only helps deadlocks and serialization failures. A unique-constraint violation fails identically every time —transactcorrectly raises it on the first try instead of retrying. chunk()on a live table. Offset paging skips or repeats rows when the table changes under you. Usechunk_by_idfor anything being written concurrently.get()on a big export. Materializes the whole result set into memory. Switch tolazy()/cursor()for an unbounded read.- Manual
rollback(). You don't call it — raising inside the block rolls back; returning commits.
How it works¶
db.transaction() is an async context manager over the connection's engine.begin(). It tracks the
active connection in a ContextVar, so a nested call sees the open transaction and issues
begin_nested() (a SAVEPOINT) instead of a second BEGIN. The outermost frame additionally enters
the event dispatcher's transaction context outside engine.begin(), so the after-commit buffer
flushes strictly after the real COMMIT and is discarded on rollback. Streaming reads
(cursor/lazy/stream) use SQLAlchemy's server-side cursor (.stream()); chunk_by_id rewrites
the builder's where/order/limit per page and restores them on exit, so the builder is left
untouched for reuse.
See also¶
- Queries — the builder these streaming and locking methods hang off.
- Events —
after_commitdispatch and the transaction buffer. - Console — long-running commands (backfills, pruning) that stream over big tables.
- Telemetry — each statement is traced as a
db <OP>span (statement only, never bind values).