Skip to content

Multi-document transactions

Sometimes a single operation genuinely has to change several documents, and either all of them must succeed or none of them may. The textbook case is moving money: debit one account and credit another. If the debit lands but the credit fails, money has vanished. When the documents involved cannot be merged into one — because they are separate accounts, or live in separate collections — MongoDB offers a real multi-document transaction. It gives you the full ACID guarantee across every operation inside it: they all commit together or they all roll back together.

The price is that a transaction needs a session, needs a replica set (or a sharded cluster) and costs more than an ordinary write. So this is a deliberate tool, not a default. Every output below was captured on this Mac against mongo 8.3.11, mongosh 2.11.1 and Node driver 7.7.0; the Python, Go and Rust tabs mirror the verified shell/Node call but are not executed by the course’s automated checker.

The mongo:8 container from the setup lesson started without --replSet is a standalone server, and a transaction on it fails at the first write. Real output from a throwaway standalone mongo:8.3.11 container (Node driver, and mongosh gives the same text):

MongoServerError: This MongoDB deployment does not support retryable writes. Please add retryWrites=false to your connection string.

A change stream on the same standalone fails too (MongoServerError 40573: The $changeStream stage is only supported on replica sets or mongos). Start a one-node replica set instead — --replSet plus one rs.initiate() is all it takes, and it is what the course’s own harness runs:

scripts/replset-one-node.sh
docker run -d --name mongo-rs --memory 1g -p 27017:27017 mongo:8 \
mongod --replSet rs0 --bind_ip_all --wiredTigerCacheSizeGB 0.5
docker exec mongo-rs mongosh --quiet --eval 'rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "localhost:27017" }] })'
docker exec mongo-rs mongosh --quiet --eval 'db.hello().isWritablePrimary' # true

Connect with mongodb://localhost:27017/?replicaSet=rs0 (or directConnection=true) from the host.

The flow is always the same. You start a session, then run your operations inside a transaction tied to that session. The driver helper withTransaction wraps the whole thing: it begins the transaction, runs your callback, commits if it returns cleanly, and aborts (rolling everything back) if it throws. It also retries the callback on TransientTransactionError and the commit on UnknownTransactionCommitResult, which is why you should prefer it over starting and committing by hand.

flowchart TD
  S["startSession"] --> B["Begin transaction"]
  B --> O1["Debit the source account"]
  O1 --> O2["Credit the destination account"]
  O2 --> C{"Both succeeded?"}
  C -->|"Yes"| COMMIT["Commit — both changes become visible together"]
  C -->|"No"| ABORT["Abort — every change is rolled back"]
A transaction begins in a session, runs its operations, then commits all changes together or aborts and rolls every one back

We seed two accounts, then move three units from Ada to Linus. Every operation passes the session, which is what binds it to the transaction:

mongosh/txn-seed.js
db.accounts.insertMany([
{ name: "Ada", balance: 10 },
{ name: "Linus", balance: 4 },
]);
assert.strictEqual(db.accounts.countDocuments(), 2);
mongosh/txn-transfer.js
const session = db.getMongo().startSession();
session.withTransaction(() => {
const accounts = session.getDatabase(db.getName()).accounts;
accounts.updateOne({ name: "Ada" }, { $inc: { balance: -3 } });
accounts.updateOne({ name: "Linus" }, { $inc: { balance: 3 } });
});
session.endSession();
const rows = db.accounts.find({}, { _id: 0 }).toArray();
console.log(rows);
assert.strictEqual(rows.reduce((s, r) => s + r.balance, 0), 14);
assert.strictEqual(rows.find((r) => r.name === "Ada").balance, 7);
[ { name: 'Ada', balance: 7 }, { name: 'Linus', balance: 7 } ]
[ { name: 'Ada', balance: 7 }, { name: 'Linus', balance: 7 } ]

Balances went 10/4 to 7/7 and the total stayed 14. No reader saw the moment where Ada’s three units had left her but not yet arrived for Linus.

In Compass: Compass does not drive transactions from its document editor, but you can open the mongosh tab embedded at the bottom of the window and run the session-based script there against the same connection.

If the callback throws, withTransaction aborts and nothing in the transaction survives. Here the debit succeeds inside the transaction, then the balance check fails:

app/txn-abort.mjs
const accounts = db.collection("accounts");
await accounts.deleteMany({}); // fresh start
await accounts.insertMany([{ name: "Ada", balance: 10 }, { name: "Linus", balance: 4 }]);
const session = client.startSession();
let message = null;
try {
await session.withTransaction(async () => {
await accounts.updateOne({ name: "Ada" }, { $inc: { balance: -30 } }, { session });
const ada = await accounts.findOne({ name: "Ada" }, { session });
console.log("inside the transaction, Ada sees:", ada.balance);
if (ada.balance < 0) throw new Error("insufficient funds");
await accounts.updateOne({ name: "Linus" }, { $inc: { balance: 30 } }, { session });
});
} catch (e) { message = e.message; }
finally { await session.endSession(); }
const rows = await accounts.find({}, { projection: { _id: 0 } }).toArray();
console.log("error:", message, "| after:", rows);
if (message !== "insufficient funds") throw new Error("expected the callback error");
if (rows.find((r) => r.name === "Ada").balance !== 10) throw new Error("debit should have been rolled back");
inside the transaction, Ada sees: -20
error: insufficient funds | after: [ { name: 'Ada', balance: 10 }, { name: 'Linus', balance: 4 } ]

Inside the transaction Ada already saw −20 (the transaction reads its own writes); outside, and afterwards, nothing changed.

Two transactions on one document: a write conflict

Section titled “Two transactions on one document: a write conflict”

A second transaction that touches a document another open transaction already wrote does not wait — it fails immediately with a WriteConflict (code 112) carrying the error label TransientTransactionError, which means “safe to retry the whole transaction”. withTransaction does that retry for you; a hand-written startTransaction loop must do it itself:

app/txn-conflict.mjs
const accounts = db.collection("accounts");
await accounts.deleteMany({}); // fresh start
await accounts.insertOne({ name: "Ada", balance: 10 });
const s1 = client.startSession(), s2 = client.startSession();
s1.startTransaction(); s2.startTransaction();
await accounts.updateOne({ name: "Ada" }, { $inc: { balance: -1 } }, { session: s1 });
let err = null;
try { await accounts.updateOne({ name: "Ada" }, { $inc: { balance: -2 } }, { session: s2 }); }
catch (e) { err = e; }
console.log(err.codeName, err.code, [...err.errorLabelSet]);
await s1.commitTransaction();
await s2.abortTransaction();
await s1.endSession(); await s2.endSession();
console.log(await accounts.findOne({ name: "Ada" }, { projection: { _id: 0 } }));
if (!err.hasErrorLabel("TransientTransactionError")) throw new Error("expected the retry label");
WriteConflict 112 [ 'TransientTransactionError' ]
{ name: 'Ada', balance: 9 }

A transaction must finish within transactionLifetimeLimitSeconds, 60 s by default (Production considerations). Read it, then see what expiry looks like:

mongosh/txn-limit.js
const p = db.adminCommand({ getParameter: 1, transactionLifetimeLimitSeconds: 1 });
console.log(p.transactionLifetimeLimitSeconds);
assert.strictEqual(p.transactionLifetimeLimitSeconds, 60);
60

To watch an expiry without waiting a minute, a throwaway single-node replica set was started with --setParameter transactionLifetimeLimitSeconds=2; a transaction that slept 3.5 s between two writes got (real output, not run by the checker):

// app/txn-expired.mjs @skip-verify needs a mongod started with transactionLifetimeLimitSeconds=2
const s = client.startSession();
s.startTransaction();
await accounts.updateOne({ name: "Ada" }, { $inc: { balance: -3 } }, { session: s });
await new Promise((r) => setTimeout(r, 3500));
await accounts.updateOne({ name: "Linus" }, { $inc: { balance: 3 } }, { session: s }); // throws
MongoServerError 251 NoSuchTransaction Transaction with { txnNumber: 2 } has been aborted. [ 'TransientTransactionError' ]

Ada’s debit was rolled back with it (balances stayed 10 / 4). Now the cost, measured: 1 000 transfers done as (a) two plain updates, (b) one withTransaction with two updates, (c) one update on a single document that holds both balances:

app/txn-cost.mjs
const accounts = db.collection("accounts"), pair = db.collection("pair");
await accounts.deleteMany({}); await pair.deleteMany({}); // fresh start
await accounts.insertMany([{ name: "Ada", balance: 1e6 }, { name: "Linus", balance: 0 }]);
await pair.insertOne({ _id: 1, ada: 1e6, linus: 0 });
const N = 1000, time = async (f) => { const t = Date.now(); for (let i = 0; i < N; i++) await f(); return Date.now() - t; };
const plain = await time(async () => {
await accounts.updateOne({ name: "Ada" }, { $inc: { balance: -1 } });
await accounts.updateOne({ name: "Linus" }, { $inc: { balance: 1 } });
});
const session = client.startSession();
const txn = await time(() => session.withTransaction(async () => {
await accounts.updateOne({ name: "Ada" }, { $inc: { balance: -1 } }, { session });
await accounts.updateOne({ name: "Linus" }, { $inc: { balance: 1 } }, { session });
}));
await session.endSession();
const single = await time(() => pair.updateOne({ _id: 1 }, { $inc: { ada: -1, linus: 1 } }));
console.log({ plainTwoUpdatesMs: plain, transactionMs: txn, singleDocumentMs: single, perTxnMs: txn / N });
if (!(single < plain && single < txn)) throw new Error("expected the single-document write to be cheapest");
{
plainTwoUpdatesMs: 2357,
transactionMs: 2121,
singleDocumentMs: 1086,
perTxnMs: 2.121
}

Numbers are from one run on this Mac: single-node replica set, no network latency, no contention. Two things stand out. The single-document write is about twice as fast as either alternative — it is one write and one acknowledgement. And, contrary to the folklore that a transaction is always slower than the same writes, here the transaction was not slower than two plain updates: with the default w: "majority" every plain update waits for its own acknowledgement, while the transaction pays once at commit (I did not isolate that reason further). What a transaction adds on top is what these numbers cannot show — snapshot pinned in the cache, WriteConflict retries under contention, the 60 s limit, and a multi-node commit — so measure with your own topology. As one more data point, the same script on the three-node replica set from the Replica sets lesson (Docker network, Node in a container) gave 1.97 s for the 1 000 plain pairs, 1.21 s for the transactions and 0.83 s for the single-document writes — the same ordering, with the transaction still ahead of two plain majority writes.

withTransaction also takes the transaction options. The docs’ recommended pair is snapshot reads and majority writes (Production considerations):

app/txn-options.mjs
const session = client.startSession();
try {
await session.withTransaction(async () => {
await db.collection("accounts").insertOne({ name: "Grace", balance: 1 }, { session });
}, { readConcern: { level: "snapshot" }, writeConcern: { w: "majority" }, maxCommitTimeMS: 5000 });
} finally { await session.endSession(); }
console.log(await db.collection("accounts").countDocuments({ name: "Grace" }));
1
  • Transactions require a replica set (or sharded cluster). The standalone error above is what you get otherwise.
  • Keep transactions short. Under 60 s is a hard limit; the shorter the transaction the less it holds a snapshot and the fewer write conflicts it can hit. Do slow work — network calls, heavy computation — outside.
  • Pass the session everywhere — the next section shows what happens when you do not.
  • On abort or error, retry the whole transaction (the docs: “When you encounter an error during individual operation in the transaction, abort and retry the transaction”).

An assistant writes a transfer with withTransaction and a comment promising all-or-nothing:

🐛 Spot the bug

// AI comment: "wrapped in withTransaction, so a failure rolls both updates back"
await session.withTransaction(async () => {
await accounts.updateOne({ name: "Ada" }, { $inc: { balance: -3 } }); // debit
await accounts.updateOne({ name: "Linus" }, { $inc: { balance: 3 } }, { session }); // credit
throw new Error("audit log unavailable");
});
Show the answer

The debit forgot { session }, so it ran outside the transaction as an ordinary write. When the callback throws, only the credit is rolled back — the debit stays committed and three units disappear. In the run below Ada ends at 7 and Linus at 4 instead of 10 and 4. Pass the session to every operation:

app/txn-forgot-session.mjs
const accounts = db.collection("accounts");
await accounts.deleteMany({}); // fresh start
await accounts.insertMany([{ name: "Ada", balance: 10 }, { name: "Linus", balance: 4 }]);
const balances = async () => Object.fromEntries((await accounts.find().toArray()).map((r) => [r.name, r.balance]));
const session = client.startSession();
try {
await session.withTransaction(async () => {
await accounts.updateOne({ name: "Ada" }, { $inc: { balance: -3 } }); // BUG: no session
await accounts.updateOne({ name: "Linus" }, { $inc: { balance: 3 } }, { session });
throw new Error("audit log unavailable");
});
} catch {}
const buggy = await balances();
await accounts.updateOne({ name: "Ada" }, { $set: { balance: 10 } });
try {
await session.withTransaction(async () => {
await accounts.updateOne({ name: "Ada" }, { $inc: { balance: -3 } }, { session }); // FIX
await accounts.updateOne({ name: "Linus" }, { $inc: { balance: 3 } }, { session });
throw new Error("audit log unavailable");
});
} catch {}
const fixed = await balances();
await session.endSession();
console.log({ buggy, fixed });
if (buggy.Ada !== 7 || buggy.Linus !== 4) throw new Error("expected the debit to survive");
if (fixed.Ada !== 10 || fixed.Linus !== 4) throw new Error("expected a full rollback");
{ buggy: { Ada: 7, Linus: 4 }, fixed: { Ada: 10, Linus: 4 } }

A transaction is a snapshot plus a buffered set of writes that belong to a session. Reads inside see one point in time (plus the transaction’s own writes); writes are invisible to everyone else until commit and are discarded on abort. Any operation without the session is simply a normal operation on the side. Conflicts are detected optimistically — the loser gets an error label and is expected to run again.

ChoiceBenefitCost
Use a multi-document transactionFull ACID across documents and collections, guarantees they commit or abort togetherMeasured above: about 2.1 ms per transfer against 1.1 ms for a single-document write (single-node set); holds a snapshot in the WiredTiger cache until commit; aborts on write conflicts and after 60 s
Avoid a transaction by redesigning the documentsFastest (single-document row above), no session, no retriesYou accept the data model’s limits, such as duplication or eventual consistency between collections
  • “withTransaction makes any code inside it transactional.” — only operations that receive the session; the buggy debit above was committed.
  • “A failed transaction is a bug to surface.” — a TransientTransactionError is a normal outcome under contention; the whole transaction should be retried, which withTransaction does.
  • “Transactions work anywhere.” — not on a standalone server (real error above).
  • “Long transactions are fine, they just take longer.” — they are aborted at 60 s by default and pin cache until then.
  • “A transaction is always much slower than the same writes.” — not on a quiet single-node set (about the same as two plain updates above: 2.1 ms vs 2.4 ms per transfer). Its cost is retries, pinned snapshots and the 60 s limit; the cheapest option is still the single-document write.

Python/Go/Rust tabs mirror the verified shell/Node behaviour; they are not executed by the course’s automated checker.

What binds an operation to a multi-document transaction?
What does withTransaction do when the callback throws a plain Error?
You start mongo:8 without --replSet and call session.withTransaction. What happens?
A second transaction updates a document an open transaction already wrote. What do you get?
What is a good rule for the body of a transaction?