MongoDB Multi-Document Transactions: When Not to Use Them
Multi-document transactions look like the safe default, but commit ambiguity and PSA availability traps make them costly. Here is when to skip them.
MongoDB added multi-document transactions in 4.0, and the moment they exist most teams reach for them the way they would in a relational database. That instinct is usually wrong. A transaction does not make your writes safe on its own, it does not remove your obligation to make operations idempotent, and on a common replica set topology it can make your database refuse writes exactly when a node is down. This post covers the three traps that cost a team its transaction-heavy design, and the single-document pattern that replaced most of it.
What a multi-document transaction actually guarantees
A MongoDB transaction gives you all-or-nothing writes across several documents. You open a session, start a transaction, run your writes against that session, and commit. If any write fails or you abort, none of them are visible. The isolation is snapshot-level, so a concurrent reader sees either the full set of committed changes or none of them.
That is a real guarantee and there are problems that need it. The trouble is that the guarantee is narrower than it looks, and the cost is wider. A transaction protects you against a partial write landing in the database. It does not protect you against your own client resending a commit, against a replica set that cannot form a majority, or against the lock contention that multi-document writes create under load. Those three gaps are where the design falls apart.
Here is the shape everyone writes first.
session, err := client.StartSession()
if err != nil {
return err
}
defer session.EndSession(ctx)
_, err = session.WithTransaction(ctx, func(sc mongo.SessionContext) (interface{}, error) {
if err := debit(sc, from, amount); err != nil {
return nil, err
}
if err := credit(sc, to, amount); err != nil {
return nil, err
}
return nil, nil
})
It reads cleanly and it demos well. The failure modes only show up in production.
The UnknownTransactionCommitResult problem
The first trap is the one people never see in testing. When you call commit, the driver sends the command to the primary and waits for acknowledgement. If the network drops or the primary steps down after your writes are durable but before the acknowledgement reaches you, the driver cannot tell whether the commit landed. It surfaces this as an error carrying the label UnknownTransactionCommitResult.
The official guidance is to retry the commit, and the driver's WithTransaction helper does this for you. That sounds like the problem is solved, but look at what retrying a commit means. You are resending an operation whose outcome you do not know. If the first commit actually succeeded, the retry commits an already-committed transaction, which MongoDB treats as a no-op, so that specific case is safe. The danger is one level up. If the whole transaction is driven by a request that itself can be retried, a client timeout, a queue redelivery, a user double-clicking, then the entire debit-and-credit can run a second time as a brand new transaction. The transaction boundary does nothing to stop that. It only guarantees that each run is atomic, not that the logical operation runs once.
// Retrying the commit is safe. Retrying the whole operation is not,
// unless debit() and credit() are idempotent by construction.
for {
err := doTransfer(ctx, from, to, amount, transferID)
if err == nil {
break
}
if hasErrorLabel(err, "TransientTransactionError") {
continue // safe to replay the transaction
}
if hasErrorLabel(err, "UnknownTransactionCommitResult") {
continue // safe to retry the commit
}
return err
}
The TransientTransactionError label marks failures where the whole transaction can be replayed safely, and UnknownTransactionCommitResult marks a commit that can be retried. Both retry loops assume the underlying work is safe to repeat. That assumption is the thing you have to build. A transaction does not give you idempotency for free. If you need the transfer to apply exactly once, you still need a stable idempotency key, usually a transferID you record and check inside the transaction so a replay becomes a no-op. Once you have that key, most of what the transaction was buying you is already handled by the key.
The PSA availability trap with w=majority
The second trap is structural and it hits availability, not correctness. Transactions commit with a write concern of w=majority by default, and you want that, because a transaction that commits with a weaker concern can be rolled back on failover, which defeats the point. The problem is what majority means on a Primary-Secondary-Arbiter topology.
PSA is a popular three-node layout because an arbiter is cheap. It votes in elections but holds no data, so you pay for two data-bearing nodes and still get failover. The catch is that an arbiter cannot acknowledge a write. Majority on a three-member set is two nodes, and only two of your three members carry data. If either data-bearing node goes down, you have one node that can acknowledge a majority write and one that cannot. The set stays writable for a normal insert, but a w=majority write, which every transaction commit is, can no longer reach majority. Commits block or time out until the second data-bearing node comes back.
| Topology | Data nodes | Majority | Tolerates 1 data node down for w=majority |
|---|---|---|---|
| PSA (Primary-Secondary-Arbiter) | 2 | 2 | No |
| PSS (Primary-Secondary-Secondary) | 3 | 2 | Yes |
So the topology that looked like it tolerated a node failure does tolerate it for ordinary writes, but not for transactions. You discover this during the exact incident you built the replica set to survive. A secondary dies at 3am, ordinary traffic keeps flowing, and every transactional write path hangs. The fix is either PSS, which costs a third data-bearing node, or not requiring majority-acknowledged multi-document commits on that path at all. If you were not using transactions there, a single-document write with w=1 would have kept serving.
The performance and lock cost
The third trap is quieter and shows up as latency. A transaction holds resources for its whole duration. It pins a snapshot, so the storage engine has to keep older versions of documents readable for the life of the transaction, and it takes document-level locks that other writers to the same documents wait behind. Under contention, transactions that touch hot documents serialize against each other, and a slow transaction makes every writer touching its documents slower.
MongoDB also puts hard limits around transactions that push you toward keeping them small. The default transaction lifetime is 60 seconds, after which the server aborts it. A transaction that grows past 16MB of oplog changes has to split internally and costs more. None of these are fatal, but together they mean a transaction is not a place to do bulk work, and a transaction that spans a slow external call or a large batch is a design mistake. The healthy transaction is a few writes to a few documents, held for milliseconds. The moment yours is not that, the transaction is the wrong tool, not a tool you need to tune.
The pattern that replaces most transactions
Most invariants that people reach for a transaction to protect do not actually span multiple documents. They span multiple fields of one document, or they are a single logical fact that you can model as one document. MongoDB updates a single document atomically with no transaction at all, and that atomic single-document update plus an idempotent upsert covers a surprising share of the cases.
Take the transfer example, which looks like the textbook two-document transaction. If the balances live in one account document as a map of currencies, the whole operation is a single atomic update. If they must live in separate documents, model the transfer itself as a document with a deterministic id, and let each side apply idempotently against that id.
// One document, one atomic update. No transaction, no majority commit
// required, and it stays writable when a data node is down.
res, err := accounts.UpdateOne(ctx,
bson.M{"_id": from, "balance": bson.M{"$gte": amount}},
bson.M{"$inc": bson.M{"balance": -amount}},
)
if err != nil {
return err
}
if res.ModifiedCount == 0 {
return ErrInsufficientFunds // the guard failed atomically
}
The filter carries the invariant. The balance >= amount condition and the $inc are evaluated together on the server against a single document, so there is no window where a concurrent writer sneaks between the check and the decrement. Two racing withdrawals cannot both pass the guard, because the second one sees the already-decremented balance. This is the same safety a transaction would give you for the single-document case, without the commit ambiguity, without the majority requirement, and without the lock held across two documents.
When the operation genuinely produces a record that must exist exactly once, use a deterministic id and an upsert so a retry overwrites instead of duplicating.
// Idempotent by id. First call inserts, every retry overwrites identical
// data, the row count never grows. Safe to replay from any retry loop.
id := TransferID(from, to, requestID)
_, err := transfers.UpdateByID(ctx, id,
bson.M{"$setOnInsert": bson.M{
"from": from, "to": to, "amount": amount, "at": time.Now(),
}},
options.Update().SetUpsert(true),
)
This is the same idea behind deterministic ULIDs for idempotent upserts. The id is derived from what identifies the operation, so the second attempt targets the same id and becomes a no-op. Between the atomic guarded update and the idempotent upsert, the transaction disappears from most paths, and with it the three traps above. That is why a team that starts transaction-first tends to walk it back to a non-transactional design once the incidents teach them what the transaction was actually costing.
When you actually need a transaction
None of this means transactions are never right. The honest case is a genuine all-or-nothing invariant that spans documents you cannot merge into one, where no single-document guard and no idempotency key can express the constraint. Moving an item between two independently-queried collections where a reader must never see it in both or neither is a real example. So is a bookkeeping ledger where a debit line and a credit line in separate documents must both exist or neither, and where the pair is queried independently often enough that you cannot fold them into a single document.
The test is simple. Ask whether the invariant truly requires two or more documents to change together, and whether a reader can observe the intermediate state in a way that breaks a rule. If the answer to both is yes, a transaction is the right tool and you should accept its costs deliberately, which means choosing a PSS topology so majority commits survive a node loss, keeping the transaction small and fast, and still making the enclosing operation idempotent so a replay does not double-apply. If the answer to either is no, you are reaching for a transaction to avoid thinking about the data model, and the model has a simpler answer.
The checklist before you reach for a transaction
- The invariant genuinely spans multiple documents that cannot be modeled as one, not just multiple fields of a single document.
- A concurrent reader can actually observe a broken intermediate state, so atomicity across the documents is required and not merely tidy.
- The enclosing operation is idempotent anyway, keyed by a stable id, because the commit can return
UnknownTransactionCommitResultand the whole request can be retried above the transaction. - The replica set is PSS, not PSA, so a
w=majoritycommit still succeeds with one data-bearing node down. - The transaction is small and short, a few documents held for milliseconds, with no slow external call or bulk write inside it.
Clear all five and a transaction is doing work nothing else can do, and its cost is a price you chose. Fail the first two and the single-document atomic update plus an idempotent upsert gives you the same invariant with more availability and less contention. The default should be the simpler design, and the transaction should be the exception you can justify one path at a time.
Related posts
The retryWrites=false Trap in Firestore's MongoDB Compatibility
Firestore speaks the MongoDB wire protocol, but rejects retryable writes. If your driver defaults to on, writes fail in a way that looks random. Here is why, and the fix.
Deterministic ULIDs for Idempotent Upserts
When a database forbids automatic write retries, every write must be idempotent on its own. A content-derived id makes that free. Here is the derivation and the one invariant that keeps it safe.
Idempotent Credit Ledgers for Refund-Then-Recover Retries
A failed job gets its credits refunded. Then a retry succeeds. Reclaiming them without double-charging needs an idempotent ledger and atomic guards.
Hot-Swapping Cron Schedules with MongoDB Change Streams
Hardcoded cron means a redeploy every time a schedule changes. Store schedules in the database and have a daemon react to edits with change streams. Here is the design and its failure handling.
Load Balancing a GPU Worker Pool with Redis Keyspace Notifications and Leases
GPUs are expensive and run one heavy job at a time, so round-robin routing stalls behind busy workers. Here is a busy-aware scheduler built from Redis leases and keyspace notifications.
Lease Locks with CAS and Heartbeat for Single-Runner Jobs
A plain lock deadlocks forever when the holder dies. A lease expires. Here is how to build one with compare-and-swap acquisition and a heartbeat, and the failure modes that decide the design.