SpacetimeDB Transactions and Consistency: A Complete Guide to ACID Guarantees in the Source Code

SpacetimeDB guarantees strong ACID transactional consistency by automatically wrapping every reducer invocation in an implicit Serializable transaction while exposing explicit transaction controls through the SDK for complex procedures.

SpacetimeDB treats transactional integrity as a core architectural pillar, ensuring that every database operation maintains atomicity, consistency, isolation, and durability. Understanding how SpacetimeDB transactions and consistency are implemented at the source code level helps developers write reliable applications without manually managing commit and rollback logic. This guide examines the concrete implementation in the clockworklabs/SpacetimeDB repository, from the trait definitions in crates/datastore/src/traits.rs to the locking mechanisms that enforce Serializable isolation.

Transaction Architecture and Core Traits

The SpacetimeDB engine defines transaction behavior through two fundamental traits that separate read-only and mutable operations. These abstractions allow the system to enforce strict consistency while providing both automatic and manual transaction management.

The Tx and MutTx Traits

The transaction interface resides in crates/datastore/src/traits.rs, where the Tx and MutTx traits establish the contract for database operations. The MutTx trait specifically handles mutable transactions through three critical methods:

pub trait MutTx {
    type MutTx;
    fn begin_mut_tx(&self, isolation_level: IsolationLevel, workload: Workload) -> Self::MutTx;
    fn commit_mut_tx(&self, tx: Self::MutTx) -> Result<Option<(TxOffset, TxData, TxMetrics, Option<ReducerName>)>>;
    fn rollback_mut_tx(&self, tx: Self::MutTx) -> (TxOffset, TxMetrics, Option<ReducerName>);
}

In crates/datastore/src/locking_tx_datastore/datastore.rs, the concrete implementation of begin_mut_tx acquires a write lock on the committed state (lines 19-28), ensuring exclusive access for the transaction duration. The commit_mut_tx method (lines 54-58) writes the in-memory state and returns a TxData structure containing all row-level mutations.

Isolation Levels and Serializable Execution

The IsolationLevel enum in crates/datastore/src/traits.rs defines five isolation tiers ranging from ReadUncommitted to Serializable. However, the current Locking implementation in datastore.rs always operates at Serializable isolation—the strongest level—effectively ignoring the isolation parameter because the engine locks the entire committed state for the duration of the mutable transaction (lines 26-28).

This design choice guarantees that transactions see a stable snapshot of the database and that concurrent transactions cannot observe intermediate states or create race conditions.

How SpacetimeDB Enforces Consistency

Consistency enforcement in SpacetimeDB operates through the TxData structure and automatic constraint validation. As documented in docs/docs/00200-core-concepts/00100-databases/00100-transactions-atomicity.md, the engine ensures that only valid states become visible by aborting transactions that violate constraints.

The TxData Structure

All row-level mutations—including inserts, deletes, and table truncations—accumulate in the TxData structure during a transaction's lifetime. When commit_mut_tx succeeds, the system hands TxData to the durability layer for persistence. If any constraint violation occurs during the commit process, the entire transaction aborts, guaranteeing that the database never enters an invalid state.

This mechanism ensures atomicity: a reducer either persists all its changes or rolls them back completely. No partial writes ever reach the committed state.

Working with Transactions in Practice

SpacetimeDB provides two distinct models for transaction management: implicit transactions for reducers and explicit transactions for procedures requiring fine-grained control.

Automatic Reducer Transactions

Every reducer invocation automatically runs inside an implicit transaction. The runtime invokes begin_mut_tx at the start of each reducer call and commits or rolls back based on the return value:

#[reducer]
pub fn add_user(ctx: &ReducerContext) -> Result<(), String> {
    // All statements belong to a single transaction.
    ctx.db.users().insert(User { id: 1, name: "Alice".into() });
    ctx.db.accounts().insert(Account { user_id: 1, balance: 0 })?;
    Ok(()) // → automatic commit
}

If ctx.db.accounts().insert fails due to a unique-key violation, the reducer returns an error and nothing persists—the transaction rolls back automatically. This eliminates the need for manual transaction management in standard business logic.

Explicit Transactions in Procedures

For multi-step workflows, procedures expose the SDK's transaction API directly:

use spacetimedb::client::{SpacetimeDBClient, Tx};

pub fn transfer_funds(client: &SpacetimeDBClient, from: u64, to: u64, amount: i64) -> Result<(), String> {
    // Open a mutable transaction at Serializable isolation
    let mut tx = client.begin_mut_tx(spacetimedb::IsolationLevel::Serializable)?;
    
    // Perform reads/writes
    let from_balance = client.read_balance(&tx, from)?;
    if from_balance < amount {
        client.rollback_mut_tx(tx)?;
        return Err("Insufficient funds".into());
    }
    
    client.update_balance(&mut tx, from, from_balance - amount)?;
    let to_balance = client.read_balance(&tx, to)?;
    client.update_balance(&mut tx, to, to_balance + amount)?;
    
    // Commit ensures all writes succeed or none do
    client.commit_mut_tx(tx)?;
    Ok(())
}

The SDK translates these calls into the same begin_mut_tx and commit_mut_tx flow used by the runtime, maintaining identical ACID guarantees.

Nesting and Scheduling Behavior

Nested reducer calls execute within the same transaction context; SpacetimeDB does not support true nested transactions. This means that if an inner reducer fails, the entire outer transaction rolls back.

Conversely, scheduled reducers (#[spacetimedb::reducer] with scheduling) start fresh transactions, allowing deliberate separation of logical units of work. This distinction is crucial for designing complex workflows that require partial failure isolation versus atomic batch operations.

Implementation Deep Dive: Locking and Durability

The Locking datastore implementation in crates/datastore/src/locking_tx_datastore/datastore.rs enforces Serializable isolation by acquiring a write lock on the CommittedState at transaction start. This coarse-grained locking strategy prevents phantom reads and write skew by ensuring only one mutable transaction accesses the committed state at a time.

Once commit_mut_tx completes, the durability layer (implemented in crates/durability/src/imp/local.rs) writes the transaction to an on-disk commit log. This separation of concerns—transaction logic in the datastore and persistence in the durability layer—ensures that committed changes survive process or machine restarts while maintaining high performance for in-memory operations.

Summary

  • Every reducer is an implicit transaction that automatically commits on success or rolls back on failure, eliminating boilerplate transaction code.
  • Serializable isolation is enforced by default through coarse-grained locking of the committed state, preventing all concurrency anomalies.
  • Consistency guarantees are maintained by validating constraints during commit; any violation aborts the entire transaction via the TxData rollback mechanism.
  • Explicit transaction control is available through the SDK for procedures, using begin_mut_tx, commit_mut_tx, and rollback_mut_tx with the same ACID guarantees.
  • Durability is achieved through a separate commit-log layer that persists TxData after successful commits, ensuring data survives restarts.

Frequently Asked Questions

What isolation level does SpacetimeDB use?

SpacetimeDB currently operates exclusively at Serializable isolation, the strongest level defined in the SQL standard. While the IsolationLevel enum in crates/datastore/src/traits.rs defines multiple levels (ReadUncommitted through Serializable), the Locking implementation in datastore.rs ignores the isolation parameter and always locks the entire committed state during mutable transactions.

How do I manually control transactions in SpacetimeDB?

Procedures can open explicit transactions using the SDK's begin_mut_tx, commit_mut_tx, and rollback_mut_tx methods. This pattern is essential for multi-step workflows where you need to check intermediate conditions before committing, such as validating account balances before transferring funds between tables.

What happens if a reducer fails halfway through?

If a reducer panics or returns an Err, SpacetimeDB automatically rolls back the entire implicit transaction. Because all operations within the reducer scope write to a staging TxData structure rather than the committed state, partial writes never become visible to other transactions or persist to disk.

Do nested reducers run in separate transactions?

No. Nested reducer calls execute within the same transaction context as the parent reducer. SpacetimeDB does not implement true nested transactions (savepoints), meaning a failure in an inner reducer causes the entire outer transaction to roll back. Use scheduled reducers if you need separate transaction boundaries.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →