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.
Precondition: a replica set
Section titled “Precondition: a replica set”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:
docker run -d --name mongo-rs --memory 1g -p 27017:27017 mongo:8 \ mongod --replSet rs0 --bind_ip_all --wiredTigerCacheSizeGB 0.5docker 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' # trueConnect with mongodb://localhost:27017/?replicaSet=rs0 (or directConnection=true) from the host.
A transaction lives in a session
Section titled “A transaction lives in a session”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"] Transferring between accounts
Section titled “Transferring between accounts”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:
db.accounts.insertMany([ { name: "Ada", balance: 10 }, { name: "Linus", balance: 4 },]);assert.strictEqual(db.accounts.countDocuments(), 2);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);const accounts = db.collection("accounts");await accounts.insertMany([{ name: "Ada", balance: 10 }, { name: "Linus", balance: 4 }]);
const session = client.startSession();try { await session.withTransaction(async () => { await accounts.updateOne({ name: "Ada" }, { $inc: { balance: -3 } }, { session }); await accounts.updateOne({ name: "Linus" }, { $inc: { balance: 3 } }, { session }); });} finally { await session.endSession();}const rows = await accounts.find({}, { projection: { _id: 0 } }).toArray();console.log(rows);if (rows.reduce((s, r) => s + r.balance, 0) !== 14) throw new Error("total not preserved");if (rows.find((r) => r.name === "Ada").balance !== 7) throw new Error("debit missing");with client.start_session() as session: def transfer(s): accounts = client.library.accounts accounts.update_one( {"name": "Ada"}, {"$inc": {"balance": -3}}, session=s ) accounts.update_one( {"name": "Linus"}, {"$inc": {"balance": 3}}, session=s )
session.with_transaction(transfer)session, err := client.StartSession()if err != nil { return err}defer session.EndSession(ctx)
_, err = session.WithTransaction(ctx, func(sc mongo.SessionContext) (interface{}, error) { accounts := client.Database("library").Collection("accounts") if _, err := accounts.UpdateOne(sc, bson.M{"name": "Ada"}, bson.M{"$inc": bson.M{"balance": -3}}); err != nil { return nil, err } if _, err := accounts.UpdateOne(sc, bson.M{"name": "Linus"}, bson.M{"$inc": bson.M{"balance": 3}}); err != nil { return nil, err } return nil, nil})if err != nil { return err}let mut session = client.start_session().await?;let accounts = client .database("library") .collection::<Document>("accounts");
session .start_transaction() .and_run((), |session, _| { let accounts = accounts.clone(); async move { accounts .update_one(doc! { "name": "Ada" }, doc! { "$inc": { "balance": -3 } }) .session(&mut *session) .await?; accounts .update_one(doc! { "name": "Linus" }, doc! { "$inc": { "balance": 3 } }) .session(&mut *session) .await?; Ok(()) } .boxed() }) .await?;[ { 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.
Abort: a throw rolls everything back
Section titled “Abort: a throw rolls everything back”If the callback throws, withTransaction aborts and nothing in the transaction survives. Here the debit succeeds inside the transaction, then the balance check fails:
const accounts = db.collection("accounts");await accounts.deleteMany({}); // fresh startawait 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: -20error: 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:
const accounts = db.collection("accounts");await accounts.deleteMany({}); // fresh startawait 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 }Limits and cost
Section titled “Limits and cost”A transaction must finish within transactionLifetimeLimitSeconds, 60 s by default (Production considerations). Read it, then see what expiry looks like:
const p = db.adminCommand({ getParameter: 1, transactionLifetimeLimitSeconds: 1 });console.log(p.transactionLifetimeLimitSeconds);assert.strictEqual(p.transactionLifetimeLimitSeconds, 60);60To 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=2const 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 }); // throwsMongoServerError 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:
const accounts = db.collection("accounts"), pair = db.collection("pair");await accounts.deleteMany({}); await pair.deleteMany({}); // fresh startawait 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):
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" }));1Tips and gotchas
Section titled “Tips and gotchas”- 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”).
Reading AI-generated code
Section titled “Reading AI-generated code”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 } }); // debitawait accounts.updateOne({ name: "Linus" }, { $inc: { balance: 3 } }, { session }); // creditthrow 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:
const accounts = db.collection("accounts");await accounts.deleteMany({}); // fresh startawait 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 } }Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Use a multi-document transaction | Full ACID across documents and collections, guarantees they commit or abort together | Measured 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 documents | Fastest (single-document row above), no session, no retries | You accept the data model’s limits, such as duplication or eventual consistency between collections |
Common Misconceptions
Section titled “Common Misconceptions”- “
withTransactionmakes 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
TransientTransactionErroris a normal outcome under contention; the whole transaction should be retried, whichwithTransactiondoes. - “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.