Transactions
Atomic multi-statement operations - commit on Ok, roll back on Err.
Transactions
A transaction wraps several database statements so they either all commit or all roll back together. Without a transaction, a failure mid-way through a multi-step operation (charge card, create order, decrement stock) leaves the database in a partial state. With a transaction, the database never sees half the work.
The transaction(...) closure
use umbral::db::transaction; let order = transaction(|tx| Box::pin(async move { // All three statements join the same transaction. let order = Order::objects().on_tx(tx).create(new_order).await?; Payment::objects().on_tx(tx).create(payment).await?; Stock::objects() .filter(stock::SKU.eq(order.sku.clone())) .on_tx(tx) .update_values(delta) .await?; Ok::<_, MyError>(order)})).await?;transaction reads the ambient pool (set by App::build()), starts a transaction, runs the closure, and commits on Ok or rolls back on Err. It returns the closure's Ok value on success.
umbral::transaction_sqlite(pool, ...) and umbral::transaction_pg(pool, ...) are the backend-pinned variants for code that works with an explicit pool (or runs outside App::build()).
Threading the transaction into queries
QuerySet::on_tx(&mut tx) replaces .on(&pool). It attaches the transaction to the query so the statement executes inside the open transaction rather than on a connection from the pool.
// Read terminal inside a transaction.let rows = Post::objects() .filter(post::PUBLISHED.eq(true)) .on_tx(tx) .fetch() .await?; // Write terminals also accept on_tx.Post::objects() .filter(post::ID.eq(42)) .on_tx(tx) .delete() .await?; Post::objects() .filter(post::ID.eq(42)) .on_tx(tx) .update_values(updates) .await?;All chainable methods (filter, order_by, limit, offset) compose with on_tx the same way they compose with on:
let recent = Post::objects() .filter(post::PUBLISHED.eq(true)) .order_by(post::CREATED_AT.desc()) .limit(10) .on_tx(tx) .fetch() .await?;Manager-level transaction writes
Manager::create_in_tx(instance, &mut tx) and Manager::bulk_create_in_tx(instances, &mut tx) are the transactional equivalents of Manager::create and Manager::bulk_create.
let post = Post::objects() .create_in_tx(new_post, tx) .await?; let n = Tag::objects() .bulk_create_in_tx(tags, tx) .await?;Using on_tx(tx).create(instance) is equivalent and preferred when you are already on a QuerySet chain. The create_in_tx method is a direct-Manager shorthand that skips the intermediate QuerySet.
Commit and rollback semantics
The closure-based helpers (transaction, transaction_sqlite, transaction_pg) handle the lifecycle automatically:
- Closure returns
Ok(value)- the transaction is committed;Ok(value)is returned to the caller. - Closure returns
Err(e)- a best-effort rollback is attempted;Err(e)is returned to the caller. - The rollback in the
Errpath is best-effort: if the rollback itself fails (e.g. the connection was dropped), the original error is still surfaced.
For manual control, use begin_sqlite / begin_pg / begin to open a transaction and call tx.commit() or tx.rollback() yourself:
use umbral::db::begin_sqlite; let mut tx = begin_sqlite(&pool).await?;Post::objects().on_tx(&mut tx).create(post).await?;tx.commit().await?;The Box::pin wrapper
Because Rust's async closures do not yet support capturing mutable references across the closure boundary on stable Rust, the closure must return a pinned boxed future. Wrap the async move block in Box::pin(...):
transaction(|tx| Box::pin(async move { // ...}))This is the TxFuture<'_, T, E> type alias. If your project already uses the futures crate you can write .boxed() instead: |tx| async move { ... }.boxed().
Pitfalls
Cross-task transactions are not supported. The &mut Transaction borrow must stay on a single task. Passing it to a tokio::spawn closure is a compile error - and rightly so: the spawned task might outlive the transaction.
Do not hold a transaction across a long I/O wait. Databases lock resources for the duration of a transaction. Awaiting an external HTTP call or a slow computation inside a transaction holds those locks for its duration. If you need to do slow I/O, complete the external call before opening the transaction.
Savepoints are not supported at v1. All work inside a transaction(...) closure is one unit. Nested transactions (savepoints, BEGIN SAVEPOINT) are deferred. If you need partial-rollback within a transaction, use multiple outer transactions.
Isolation level control is deferred. The transaction starts with the database's default isolation level (SQLite: serializable; Postgres: read committed). Changing the isolation level per-transaction is a deferred feature.
.atomic() - per-call BEGIN/COMMIT wrapper
Manager::atomic() and QuerySet::atomic() flip a flag on the entry point that makes the next write terminal run inside its own BEGIN / COMMIT pair. On Err, the statement is rolled back; on Ok, it is committed. No closure, no Box::pin, no &mut Transaction to thread through the call chain.
// Single-statement INSERT inside an implicit transaction.let post = Post::objects() .atomic() .create(new_post) .await?; // Bulk INSERT wrapped in BEGIN/COMMIT.let n = Post::objects() .atomic() .bulk_create(many_posts) .await?; // UPDATE wrapped in BEGIN/COMMIT.Post::objects() .filter(post::PUBLISHED.eq(false)) .atomic() .update_values(updates) .await?; // DELETE wrapped in BEGIN/COMMIT.Post::objects() .filter(post::CREATED_AT.lt(cutoff)) .atomic() .delete() .await?;Wired terminals: Manager::create, Manager::bulk_create, QuerySet::update_values, QuerySet::delete. Other write terminals (save, delete_instance, upsert, get_or_create, update_expr) are single-statement at the DB level. An explicit BEGIN/COMMIT around them would add overhead without changing observable semantics, so they currently ignore the flag.
Builder default: atomic_transactions(true)
The safe-by-default posture: opt every wired terminal into transactional behaviour at boot, then opt specific calls out with .non_atomic() only where the perf or batching shape demands it.
umbral::App::builder() .settings(settings) .database("default", pool) .atomic_transactions(true) // every write wraps in BEGIN/COMMIT .model::<Post>() .build()?Per-call resolution order at terminal time: explicit .atomic() / .non_atomic() > builder default > false. So an app that built with .atomic_transactions(true) still lets a hot-path import opt out:
// Skip the per-row BEGIN/COMMIT overhead; the caller's outer// transaction (or a one-shot seed script) is what's protecting the// batch.Post::objects() .non_atomic() .bulk_create(thousands_of_posts) .await?;.atomic() and .on_tx() are mutually exclusive
.on_tx(tx) already runs your statement inside an open transaction. Calling .atomic() alongside it would try to start a second, separate transaction on a different connection, which would deadlock on backends without nested transactions. The framework documents .on_tx() as the winner: when both are set, .on_tx() runs and .atomic() is silently a no-op for that call.
Reach for .on_tx(tx) when you want several statements to commit or roll back together. Reach for .atomic() when you want one statement protected without writing a closure.
See also
arch.md- ambient pool design and theOnceLock-backed global.docs/specs/03-orm-querysets.md- the QuerySet surface.documentation/docs/v0.0.1/orm/models.mdx- model declarations.documentation/docs/v0.0.1/orm/signals.mdx- lifecycle signals (per-row, bulk, m2m) and the actor task-local.documentation/docs/v0.0.1/backends/sqlite.mdx- SQLite backend.