Transactions¶
Group multiple operations into a single atomic unit with the ferro.transaction() async context manager: commit on clean exit, rollback if the block raises.
Basic Usage¶
async with transaction():
checking.balance -= 25
await checking.save()
savings.balance += 25
await savings.save()
# Both writes commit together when the block exits cleanly
Every ORM operation inside the block — create, save, delete, batch update, bulk_create, queries — runs on the same database connection (connection affinity is tracked through contextvars, so it is safe under concurrent asyncio tasks: each task gets its own transaction).
Rollback on Error¶
If any exception escapes the block, everything performed inside it is rolled back and the exception re-raises:
try:
async with transaction():
checking.balance -= 1000
await checking.save()
raise RuntimeError("insufficient funds")
except RuntimeError:
pass
await checking.refresh()
# The failed write was rolled back
assert checking.balance == 75
Using Named Connections¶
A transaction is pinned to one connection. Open it against a named connection with using=, and everything inside inherits that connection — unqualified operations included. Trying to route to a different connection inside the block raises:
async with transaction(using="analytics"):
await Metric.using("analytics").create(name="clicks", value=42)
Sessions + Transactions¶
transaction() composes with sessions. If a session is active, transactions inherit that session route by default:
import ferro
async with ferro.engines.session("analytics"):
async with ferro.transaction():
await Metric.create(name="requests", value=1)
You can also pass an explicit session handle (transaction(session=my_session)) when ambient context is different and you need a deterministic override.
Session.close() raises RuntimeError while session-scoped transactions are still open. Exit all transaction() blocks before closing the session.
Raw SQL Inside a Transaction¶
transaction() yields a Transaction handle exposing execute / fetch_all / fetch_one for raw SQL on the transaction's own connection — useful for Postgres GUCs (set_config, SET LOCAL), advisory locks, or any one-off statement that doesn't fit a model:
async with transaction() as tx:
await Account.create(owner="bob")
# Raw SQL on the same connection, inside the same transaction
rows = await tx.fetch_all("SELECT COUNT(*) AS n FROM account")
The handle becomes invalid when the block exits — calling it afterwards raises RuntimeError. If you don't need it, simply write async with transaction(): and the handle is discarded. The top-level ferro.execute / fetch_all / fetch_one functions also automatically join the active transaction — see Raw SQL.
Nesting Behavior¶
Nested transaction() blocks map to savepoints on the outer transaction's connection:
- If an inner block raises, only its work is rolled back (to the savepoint); the outer transaction can continue and commit.
- If the outer block raises, everything rolls back — including inner blocks that completed "successfully".
from ferro import transaction
async def import_rows(rows: list[dict]) -> int:
imported = 0
async with transaction():
await AuditLog.create(event="import-started")
for row in rows:
try:
async with transaction(): # savepoint per row
await Record.create(**row)
imported += 1
except ValueError:
continue # this row rolled back; the import continues
return imported
A nested block never commits independently of its parent — only the outermost block's clean exit commits to the database.
Patterns¶
Keep transactions short¶
A transaction holds a pooled connection (and database locks) for its entire duration. Do slow work — HTTP calls, file I/O, expensive computation — outside the block, and keep only the database writes inside:
async def publish(post_id: int) -> None:
rendered = await render_markdown(post_id) # slow work first, no tx held
async with transaction():
post = await Post.get(post_id)
post.body_html = rendered
post.published = True
await post.save()
Error handling¶
Catch exceptions outside the block when the whole unit should roll back, and inside it only for work you genuinely want to keep partial (paired with a nested block, as above). Catching an exception inside the block and continuing means the surviving operations will commit:
async def transfer(src: Account, dst: Account, amount: int) -> bool:
try:
async with transaction():
src.balance -= amount
await src.save()
if src.balance < 0:
raise ValueError("insufficient funds")
dst.balance += amount
await dst.save()
except ValueError:
return False # nothing was committed
return True
See Also¶
- Raw SQL — placeholders, bind types, and the
Transactionhandle - Connections & Databases — named connections and pools
- Mutations — the operations you'll wrap in transactions
- Multiple Databases — routing patterns