Skip to main content
MongoDB · lesson 7 of 10
MongoDB intermediate Lesson 7 of 10

Multi-Document Transactions: When One Update Isn't Enough

Transfer money between two accounts as a single atomic transaction — then abort one on purpose and prove the balance actually rolls back, not just that the code stopped running.

By SkillByExample · Published

mongodb transactions acid sessions replica-set

Two accounts, one transfer, and a guarantee that either both balances change or neither does — even when the code decides midway through that it shouldn’t happen.

The bug: transactions on a standalone server

docker exec mongo-lesson mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const session = db.getMongo().startSession();
session.startTransaction();
try {
  session.getDatabase("bookshop").accounts.updateOne({ name: "alice" }, { $inc: { balance: -50 } });
  session.commitTransaction();
  print("committed");
} catch (e) {
  print("ERROR: " + e.message);
  session.abortTransaction();
}
'
ERROR: This MongoDB deployment does not support retryable writes. Please add retryWrites=false to your connection string.

Same underlying cause as the change-streams lesson’s error: a standalone server has no replication machinery. Multi-document transactions need the replica set from that lesson.

A successful transfer

docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
db.accounts.insertMany([{ name: "alice", balance: 100 }, { name: "bob", balance: 50 }]);
'
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const session = db.getMongo().startSession();
session.startTransaction();
const accounts = session.getDatabase("bookshop").accounts;
accounts.updateOne({ name: "alice" }, { $inc: { balance: -30 } });
accounts.updateOne({ name: "bob" }, { $inc: { balance: 30 } });
session.commitTransaction();
print("committed");
'
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
printjson(db.accounts.find({}, {_id:0}).toArray());
'
committed
[ { name: 'alice', balance: 70 }, { name: 'bob', balance: 80 } ]

Two separate updateOne calls, on two different documents, wrapped in one startTransaction()/commitTransaction() pair — both changes land together.

Aborting mid-transaction: the rollback is real

docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const session = db.getMongo().startSession();
session.startTransaction();
const accounts = session.getDatabase("bookshop").accounts;
accounts.updateOne({ name: "alice" }, { $inc: { balance: -1000 } });
const aliceBalance = accounts.findOne({ name: "alice" }).balance;
print("balance inside uncommitted transaction:", aliceBalance);
session.abortTransaction();
'
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
print("balance after abort:", db.accounts.findOne({ name: "alice" }).balance);
'
balance inside uncommitted transaction: -930
balance after abort: 70

Inside the transaction’s own session, the read sees the pending write — -930 (70 - 1000). Once abortTransaction() runs, a completely separate read (outside that session) shows 70, the original balance, as though the -1000 update never happened. This is the actual guarantee, not just “the script stopped”: the write was applied and then genuinely undone.

A real business rule enforced with an abort

docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
function transfer(from, to, amount) {
  const session = db.getMongo().startSession();
  session.startTransaction();
  const accounts = session.getDatabase("bookshop").accounts;
  try {
    const sender = accounts.findOne({ name: from });
    if (sender.balance < amount) {
      throw new Error("insufficient funds");
    }
    accounts.updateOne({ name: from }, { $inc: { balance: -amount } });
    accounts.updateOne({ name: to }, { $inc: { balance: amount } });
    session.commitTransaction();
    return "committed";
  } catch (e) {
    session.abortTransaction();
    return "aborted: " + e.message;
  } finally {
    session.endSession();
  }
}
print(transfer("alice", "bob", 1000));
printjson(db.accounts.find({}, {_id:0}).toArray());
'
aborted: insufficient funds
[ { name: 'alice', balance: 70 }, { name: 'bob', balance: 80 } ]

The insufficient-funds check runs inside the transaction, before either updateOne call — throwing sends control to the catch block, which explicitly aborts. Balances stay exactly where they were; nothing was ever really written.

Practice

1. Confirm a committed transaction's changes are visible from a completely separate connection.
docker exec mongo-rs2 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
printjson(db.accounts.find({}, {_id:0}).toArray());
'
[ { name: 'alice', balance: 70 }, { name: 'bob', balance: 80 } ]

Reading from mongo-rs2 — a different node in the same replica set, not the primary the transaction ran against — confirms the committed values replicated, not just that they exist on whichever node happened to run the transaction.

2. Try committing a transaction twice and see what actually happens the second time.
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const session = db.getMongo().startSession();
session.startTransaction();
session.getDatabase("bookshop").accounts.updateOne({ name: "alice" }, { $inc: { balance: 1 } });
session.commitTransaction();
try {
  session.commitTransaction();
  print("second commit succeeded");
} catch (e) {
  print("second commit failed: " + e.codeName);
}
print("alice balance:", db.accounts.findOne({ name: "alice" }).balance);
'
second commit succeeded
alice balance: 71

Worth checking rather than assuming: re-committing an already-committed transaction is idempotent, not an error — commitTransaction() is safe to retry (which matters for a driver automatically retrying a commit whose acknowledgment got lost on the network). The balance confirms it: 71 (70 + 1), applied exactly once, not 72 — the second call didn’t redo the increment.

3. Check both balances sum to the same total before and after several transfers, proving no money was created or destroyed.
docker exec mongo-rs1 mongosh --quiet --eval '
db = db.getSiblingDB("bookshop");
const docs = db.accounts.find({}, {_id:0,balance:1}).toArray();
print("total:", docs.reduce((sum, d) => sum + d.balance, 0));
'
total: 151

150 (the original combined 100 + 50) plus the 1 added in the previous task’s committed transfer — every successful transaction moved money between accounts without ever creating or losing any along the way.

Next: replication and failover — killing the primary and measuring how long an election actually takes.

Frequently Asked Questions

Why did a transaction fail on the standalone container with a retryable-writes error?
Multi-document transactions depend on the same replication machinery (a session-tracked oplog) that change streams need — a standalone mongod has neither. The specific error names retryable writes because that's the first replica-set-only feature the driver checks for, but the underlying cause is identical to the change-stream lesson: no replica set, no transactions.
Do I need a transaction for every multi-field update?
No — a single updateOne() or updateMany() call is already atomic for the document(s) it touches, transaction or not. Transactions matter specifically when a business operation spans multiple documents (or multiple collections) that all need to succeed or all need to fail together, like the two-account transfer in this lesson.
What happens to reads made inside a transaction that later gets aborted?
Within the same session, a read sees the transaction's own pending writes (this lesson's example shows a balance of -930 read from inside an uncommitted transaction) — but once abortTransaction() runs, every write in that transaction is discarded entirely, and a fresh read from outside the session sees the original, unmodified value, as if the transaction never happened.
Is throwing an exception enough to abort a transaction, or does something else need to happen?
An uncaught exception doesn't automatically abort anything — the code in this lesson explicitly calls session.abortTransaction() in a catch block. Without that explicit call, a transaction left open when a script exits can hold resources and eventually time out, rather than being cleanly rolled back the moment the failure is known.
Should every write in my application go through a session and a transaction, just in case?
No — transactions carry real overhead (session tracking, the two-phase commit machinery underneath) that a single-document write doesn't need to pay. Reach for one specifically when a single business operation truly requires multiple documents to change together atomically; default to plain single-document writes otherwise.