Atomic Multi-Document Updates in FaunaDB Without Application Locks
FaunaDB runs each FQL query as a single atomic transaction with ACID guarantees across documents. Use that to enforce multi-document invariants without application locks, with index-driven reads and built-in temporal history.
19 Dec 2025, 09:27 UTC

Moving inventory between two warehouses needs two writes to stay consistent: decrement stock in one document and increment it in another, plus an audit entry. With separate calls you can get a partial update if a request fails or two clients race. The useful takeaway is that FaunaDB executes each FQL query as a single atomic transaction with ACID guarantees across multiple documents and collections, so the read-modify-write set either commits together or not at all.
The consistency problem with multi-document writes
ACID means Atomicity, Consistency, Isolation, Durability. In practice it prevents the classic split-brain where one document is updated and the other is not. Application-level locks or two-phase commit add latency and operational risk. FaunaDB moves that guarantee into the query boundary.
A single FQL query sees a consistent snapshot and can read several documents, compute new values, and write back within the same transaction. If any part fails, the whole query aborts and no partial changes persist.
How a query becomes a transaction
FQL is FaunaDB's query language. The database treats the entire query as one transaction. Reads and writes inside the query are ordered by the query planner and executed against the same snapshot.
Conditional updates are expressed with predicates. You read current values, test a condition, and only then write. Because the check and the write share the transaction, another concurrent query that changes the same data will cause a conflict and the transaction retries or aborts rather than silently overwriting.
Temporal history is retained automatically. Each document keeps prior versions, so point-in-time reads are possible without external change data capture.
Index-driven reads shape what you can transact on
Reads in FaunaDB are index-driven. An index declares terms for filtering and values for projection, optionally with bindings for computed fields. The query planner requires a matching index for efficient access.
Designing indexes up front is an engineering decision. A query that cannot be covered by an index will be rejected or will require a full scan, which is impractical at scale. For transactional updates you need indexes that can locate the documents you intend to modify in a single, deterministic lookup.
Worked example: conditional transfer between two documents
Run this in Fauna Shell or via a client SDK with a role that has read and write on the target collections. Replace placeholders with your own names.
// Collections: warehouses, audit_logs
// Index: warehouses_by_id with term source: ['id'] and value: ['ref']
// Index: warehouses_by_item with terms [item_id, warehouse_id]
Let(
{
itemId: "item123",
fromRef: Ref(Collection("warehouses"), "whA"),
toRef: Ref(Collection("warehouses"), "whB"),
qty: 5
},
Let(
{
fromDoc: Get(Var("fromRef")),
toDoc: Get(Var("toRef")),
fromStock: Select(["data", "stock"], Var("fromDoc")),
canTransfer: GT(Var("fromStock"), Var("qty"))
},
If(
Var("canTransfer"),
Do(
[
Update(Var("fromRef"), { data: { stock: Subtract(Var("fromStock"), Var("qty")) } }),
Update(Var("toRef"), { data: { stock: Add(Select(["data", "stock"], Var("toDoc")), Var("qty")) } }),
Create(Collection("audit_logs"), { data: { item: Var("itemId"), from: Var("fromRef"), to: Var("toRef"), qty: Var("qty"), ts: Now() } })
]
),
Abort("insufficient stock")
)
)
)Expected checks after the query: both warehouse documents reflect the new stock values and an audit document exists, or nothing changes if the predicate fails. Verify by reading the two warehouse refs and the audit collection filtered by item.
Risks: the transaction size and compute are bounded. Large batch operations can exceed limits and require chunking. Concurrent updates to the same documents increase contention and may cause retries.
Trade-offs and limits
Atomicity simplifies correctness but concentrates work in one query. Very large read-modify-write sets increase latency and the chance of abort under contention. FaunaDB transactions apply within a single FQL query and are subject to compute and size limits.
Because reads are index-driven, you must predefine indexes for the access patterns you need inside transactions. Changing an index later is possible but changes query plans and may require re-testing.
Feature names, syntax and limits are version-sensitive across FQL versions and FaunaDB releases. The concepts above reflect commonly documented core behavior.
Check it yourself
Create a test database with two warehouse documents and an audit collection. Run a single FQL query that conditionally updates both documents based on a shared predicate, then inspect final state to confirm all-or-nothing behavior. Define a simple index with terms and values, query it successfully, then attempt a query that cannot be covered by an index to observe planner requirements. Use a time-travel read on an existing document at an earlier timestamp and compare fields to the current version to confirm history availability.
Use atomic transactions for invariants that span documents. Keep transactional queries small, index-driven, and predicate-guarded, and chunk larger workloads.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.