This version is in beta. Some features may change before release.

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

Code
rust
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.

Code
rust
// 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:

Code
rust
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.

Code
rust
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 Err path 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:

Code
rust
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(...):

Code
rust
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.

Code
rust
// 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.

Code
rust
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:

Code
rust
// 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 the OnceLock-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.
ormdatabasetransactionsatomicity