The idea: a transfer that happens all at once or not at all

Moving 30 from Ann to Bob is two updates on two documents. If only the first one happens, 30 is gone. A multi-document transaction (MongoDB 4.0 for replica sets, 4.2 for sharded clusters) groups the two updates so that nobody ever sees one without the other: other readers see the old balances until the commit, then both new ones together, and the secondaries copy both in one step.

Before the commit, Ann's update chain has an uncommitted version 70 above the committed 100 at t10, and Bob's has an uncommitted 80 above 50 at t11; other sessions still read 100 and 50. After commitTransaction both new versions carry the same timestamp t12, and the oplog gets one applyOps entry with both updates.
Until the commit the writes are private versions; the commit stamps them all with one timestamp and writes one oplog entry.

The canvas shows two driver sessions on the left, the server in the middle with both documents and their update chains (every version of the document, newest first), and the oplog on the right. The collection is test.accounts with {_id: 1, owner: "Ann", balance: 100} and {_id: 2, owner: "Bob", balance: 50}. How a single write goes through mongod, the WiredTiger cache, the journal and the replica set is on How MongoDB Runs a Query; this page assumes that and looks at what changes when several writes must be one.

Sessions, lsid and txnNumber

A transaction always lives in a logical session. The driver creates the session and gives it an lsid (a UUID). Each transaction in the session gets the next txnNumber. Every command of the transaction carries both, and the first one also says startTransaction: true, so the server can tie commands from any connection to the right transaction:

{update: "accounts", updates: [{q: {_id: 1}, u: {$inc: {balance: -30}}}],
 lsid: {id: UUID("a1f3…")}, txnNumber: 1, startTransaction: true,
 autocommit: false, readConcern: {level: "snapshot"}}
{update: "accounts", updates: [{q: {_id: 2}, u: {$inc: {balance: 30}}}],
 lsid: {id: UUID("a1f3…")}, txnNumber: 1, autocommit: false}
{commitTransaction: 1, lsid: {id: UUID("a1f3…")}, txnNumber: 1, autocommit: false}

The server keeps the state of each session's newest transaction in memory (the canvas calls it the session catalog): its number, whether it is in progress, committed or aborted, and its WiredTiger transaction. A command with an older txnNumber is refused, which is also how retried commands are recognised.

Snapshot reads: a read timestamp and update chains

When the transaction starts, the server opens a WiredTiger transaction with a read timestamp: the newest point at which everything before it is committed. On the canvas that is the cluster time, t11 at the start. The transaction then reads that snapshot for its whole life, whatever others commit meanwhile. Its own writes are uncommitted versions at the head of the documents' update chains, in orange.

Version on the chainThe writer itselfAnother transaction (read ts t11)
70 · S1 txn 1 · uncommittedsees itskips it
70 · committed t12sees itskips it: t12 is after its snapshot
100 · committed t10(older)sees it

In Demo: another session reads the old snapshot, S2 reads in the middle of S1's transfer and gets Ann 100, Bob 50: the sum is 150, never 120. S1 commits at t12, and S2 reads again in the same transaction: still 100 and 50, because its snapshot is t11. Only S2's next transaction, with read timestamp t12, sees 70 and 80. The ▶ on each document marks the version the read used. This is snapshot isolation, the same idea as in MVCC and Isolation Levels, without an undo log: the old versions stay on the update chain (and in the history store) as long as a snapshot needs them.

Commit: one timestamp, one oplog entry

commitTransaction gives every write of the transaction the same commit timestamp. A reader's snapshot is either before it, and sees neither write, or at or after it, and sees both. Then the server writes one oplog entry for the whole transaction (Demo: transfer commits):

{ts: t12, op: "c", ns: "admin.$cmd", lsid: {id: UUID("a1f3…")}, txnNumber: 1,
 o: {applyOps: [{op: "u", ns: "test.accounts", o2: {_id: 1}, o: {$set: {balance: 70}}},
                {op: "u", ns: "test.accounts", o2: {_id: 2}, o: {$set: {balance: 80}}}]}}

A secondary applies an applyOps entry as a unit, so a read on a secondary cannot see half a transfer either. Like every oplog entry it stores the result ($set: 70), not the operation ($inc: −30), so applying it twice is harmless. A very large transaction is split over several oplog entries, chained by prevOpTime, but it still becomes visible at one timestamp. A transaction that only read writes nothing to the oplog.

Write conflicts and TransientTransactionError

Two transactions may read the same document, but only one may write it. WiredTiger checks the head of the update chain when a transaction writes: if it is an uncommitted version of another transaction, or a version committed after the writer's read timestamp, the write fails with WriteConflict (code 112). Inside a transaction the server does not wait: it aborts the transaction at once. (A plain write outside a transaction that hits a transaction's document waits and retries until the transaction ends.)

The error carries the label TransientTransactionError, meaning "the whole transaction may succeed if you run it again". The driver's session.withTransaction(callback) does exactly that: it starts a new transaction (txnNumber + 1, a new snapshot), runs the callback again and commits, until it succeeds or 120 s have passed. In Demo: write conflict S1 has an uncommitted 70 on Ann when S2 tries to charge a fee of 5; S2 is aborted. S1 commits at t12, and S2's retry reads 70 at snapshot t12, writes 65 and commits at t13. The total is 145: the fee was taken exactly once and the transfer was not lost.

await session.withTransaction(async () => {
    await accounts.updateOne({_id: 1}, {$inc: {balance: -30}}, {session});
    await accounts.updateOne({_id: 2}, {$inc: {balance: 30}}, {session});
});   // retries on TransientTransactionError; retries the commit on UnknownTransactionCommitResult

A second label, UnknownTransactionCommitResult, is set when the commit itself failed in a way that leaves the outcome open (a network error, a failover). Then only commitTransaction is retried, with the same txnNumber, and the server answers from its session state whether the commit already happened.

Limits: 60 seconds and a few others

An open transaction pins its snapshot, so WiredTiger must keep every newer version of every document it could read. The server therefore aborts any transaction older than transactionLifetimeLimitSeconds (default 60). In Demo: 60 s lifetime limit, S1 updates Ann and then stalls for 61 s; the server's periodic check aborts the transaction and drops its write. S1's next command still says txnNumber 1 and gets NoSuchTransaction (code 251), which is also labelled TransientTransactionError, so withTransaction runs the transfer again as txnNumber 2.

Setting or limitDefaultWhat happens
transactionLifetimeLimitSeconds60 sThe server aborts older transactions.
maxTransactionLockRequestTimeoutMillis5 msA transaction that cannot get a lock (for example during an index build) aborts quickly instead of waiting.
withTransaction retry budget120 sThe driver stops retrying and throws.
Oplog entry size16 MBBigger transactions are split over several entries (4.2+).

Keep transactions short: read and write only what is needed, never wait for a user inside one, and remember that a transaction that conflicts often is retried often. When one document can hold the change (an embedded array, a counter), a single-document write is atomic without any transaction.

Across shards: two-phase commit

On a sharded cluster the client talks to mongos. mongos picks one read timestamp (atClusterTime) for the transaction and adds it, with startTransaction, to the first command each shard receives, so every shard reads the same snapshot. If the transaction wrote to one shard only, the commit is the simple one above. If it wrote to several, mongos hands the commit to a transaction coordinator that runs on the first shard the transaction wrote to, and the coordinator runs two-phase commit (Demo: 2 shards):

Time runs down. mongos sends coordinateCommitTransaction to the coordinator on shard A, which writes the participant list. It sends prepareTransaction to shard A and shard B; A prepares at t12 and B at t13, each writing an applyOps prepare entry, and both reply with their prepare timestamp. The coordinator writes the decision commit with commitTimestamp t13, sends commitTransaction to both, both make the write visible at t13 and acknowledge, the coordinator deletes its document and replies to mongos.
Each shard prepares at its own timestamp; the coordinator commits everyone at the largest one, so the transaction is visible at one point in time on every shard.
  1. The coordinator writes the participant list to config.transaction_coordinators, so a new primary can finish the job after a failover.
  2. Prepare: each participant writes its part to its oplog as applyOps with prepare: true, majority-committed, and replies with its prepareTimestamp (t12 on A, t13 on B). A prepared shard may no longer abort on its own.
  3. Decision: the coordinator picks commitTimestamp = the largest prepare timestamp (t13) and writes the decision durably.
  4. Commit: each participant writes a commitTransaction oplog entry and makes its version visible at t13. Then the coordinator deletes its document and mongos replies {ok: 1}.

Between prepare and commit a read that needs a prepared document waits (a prepare conflict), because its outcome is not known yet. That window is the price of atomic commits across shards; the Saga vs Two-Phase Commit page compares it with the alternative.

Compared with other databases

MongoDBPostgreSQL / InnoDBRedis MULTI/EXEC
Isolationsnapshot (readConcern "snapshot")read committed or repeatable read by defaultruns queued commands back to back
Two writers, one rowsecond one fails at once (WriteConflict), retrysecond one waits for the row lockno conflict: commands are serialised; WATCH detects changes
Rollbackdrop the uncommitted versionsundo log / dead tuplesnone
Time limit60 s by defaultnone by defaultnone

See also Redis Transactions and Scripts and Database Replication.

What the page leaves out

  • Write concern and read concern "majority" of the commit; here every commit is acknowledged at once (see write concern).
  • Prepare conflicts, coordinator failover and recovery, and the abortTransaction decision in two-phase commit.
  • Large transactions split over several oplog entries, and the history store that keeps old versions when the cache is full.
  • Locks: transactions take intent locks on the database and collection; DDL and index builds can block them.
  • Retryable writes (the same lsid and txnNumber on a single write, without a transaction), causal consistency and reading from secondaries.
  • Time: every command takes 1 s here, so the 60 s limit shows up only with the Wait button.