MongoDB Mastery
MongoDB Mastery lessons / Module 18: Transactions

Multi-document transactions

Level 3: Advanced28 min

ACID across documents and collections: sessions, the callback API, retries, write conflicts, limits and when not to use transactions.

This lesson is interactive. Run every example against an in-browser MongoDB, see explain() plans drawn as trees, and take the quiz.
Open the interactive lesson
You will learn to
  • Use sessions and withTransaction
  • Understand snapshot isolation and write conflicts
  • Handle TransientTransactionError and UnknownTransactionCommitResult
  • Know the limits: 60 s default lifetime, oplog size, performance cost

conceptThe concept

A single-document write is always atomic. Multi-document transactions (replica sets since 4.0, sharded clusters since 4.2) give all-or-nothing semantics with snapshot isolation across documents, collections and databases.

const session = db.getMongo().startSession()
session.withTransaction(() => {
  const s = session.getDatabase("shop")
  s.products.updateOne({ _id: "P001", stock: { $gte: 1 } }, { $inc: { stock: -1 } })
  s.orders.insertOne({ ... })
}, { readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } })

withTransaction (the callback API) retries the whole callback on TransientTransactionError (e.g. WriteConflict) and retries the commit on UnknownTransactionCommitResult. With the core API (startTransaction/commitTransaction) you must write those retry loops yourself.

Two transactions modifying the same document conflict: the second writer gets a WriteConflict immediately and must retry. A non-transactional write to a document locked by an open transaction waits for it to finish (up to maxTransactionLockRequestTimeoutMillis).

whyWhy it matters

Transactions make some invariants simple (transfer money between two accounts, decrement stock and create an order). But they cost latency, hold resources and abort under contention, so a good schema uses them for the few flows that truly span documents.

Use caseReal-world scenario

Checkout: decrement stock for each product, insert the order, and record the payment intent. If any step fails (stock went to zero), the whole transaction aborts and nothing is half-written.

Try itExamples

Run and edit these in the interactive lesson.

Checkout in a transaction
const session = db.getMongo().startSession()
const s = session.getDatabase("shop")
session.withTransaction(() => {
  const r = s.products.updateOne({ _id: "P005", stock: { $gte: 2 } }, { $inc: { stock: -2 } })
  if (r.modifiedCount !== 1) throw new Error("out of stock")
  s.orders.insertOne({ orderNo: "ORD-TXN-1", customerId: 1, status: "paid", createdAt: new Date(), items: [{ productId: "P005", qty: 2, price: 100 }], total: 200 })
})
db.orders.findOne({ orderNo: "ORD-TXN-1" }, { status: 1, total: 1 })
session.endSession()
Isolation: uncommitted writes are invisible
const s1 = db.getMongo().startSession()
s1.startTransaction()
s1.getDatabase("shop").customers.updateOne({ _id: 3 }, { $set: { tier: "platinum" } })
print("inside txn :", s1.getDatabase("shop").customers.findOne({ _id: 3 }).tier)
print("outside txn:", db.customers.findOne({ _id: 3 }).tier)
s1.abortTransaction()
print("after abort:", db.customers.findOne({ _id: 3 }).tier)
Write conflict between two transactions
const a = db.getMongo().startSession(), b = db.getMongo().startSession()
a.startTransaction(); b.startTransaction()
a.getDatabase("shop").products.updateOne({ _id: "P007" }, { $inc: { stock: -1 } })
try { b.getDatabase("shop").products.updateOne({ _id: "P007" }, { $inc: { stock: -1 } }) }
catch (e) { print(e.codeName, JSON.stringify(e.errorLabels)) }
a.commitTransaction()
try { b.abortTransaction() } catch (e) {}

internalsUnder the hood

A transaction runs on a WiredTiger snapshot; its writes are private until commit. On commit, all operations are written to the oplog as one applyOps entry (or a chain of entries for large transactions since 4.2). The default transactionLifetimeLimitSeconds is 60: longer transactions are aborted, because an open snapshot pins old versions in the cache and creates cache pressure. In sharded clusters, a transaction touching several shards uses two-phase commit coordinated by one shard.

Rules of thumbKeep transactions short (milliseconds, not seconds), touch few documents (under 1,000 as a guideline), never wait for user input inside one, and avoid creating collections or indexes inside them.

Avoid / preferDo this, not that

Avoid
session.withTransaction(() =>
  orders.updateOne({ _id: id }, { $set: { status: "paid" } }, { session }))
Prefer
db.orders.updateOne({ _id: id }, { $set: { status: "paid" } })

A single-document write is already atomic. Wrapping it in a transaction only adds round trips and overhead.

Avoid
await session.withTransaction(async () => {
  await orders.insertOne(order, { session })
  await sendConfirmationEmail(order)
})
Prefer
await session.withTransaction(async () => {
  await orders.insertOne(order, { session })
  await outbox.insertOne({ type: "order-confirmation", orderId: order._id }, { session })
})
// a worker reads the outbox and sends the email

The callback may run several times on transient errors, so the email could go out twice, and locks are held while a slow API call waits. The outbox pattern commits the intent atomically and sends it afterwards.

PitfallsCommon mistakes

  • Using transactions to compensate for a relational-style schema; embedding often removes the need.
  • Long-running transactions (batch jobs updating 100,000 documents in one transaction).
  • Not retrying on TransientTransactionError with the core API.
  • Doing network calls or user interaction inside a transaction.
  • Expecting transactions on a standalone mongod; they need a replica set (even a single-node one).

LimitsWhen not to use it

Transactions are not for bulk loads or long jobs: keep them well under the 60-second default lifetime and to a modest number of documents, because old versions stay pinned in the cache until they finish. They are also not a substitute for schema design; if most operations need one, the data is probably split across too many documents.

SecuritySecurity notes

Transactions protect integrity rules that span documents, such as double-entry ledgers: a debit that commits without its credit creates money. Keep both legs in one transaction, and make the ledger append-only for the application role (grant insert, not update or remove).

ChallengeTry it yourself

In a scratch collection accounts_lab with alice at 100 and bob at 50, transfer 25 from alice to bob in a transaction that refuses to overdraw, then show both balances.

Show one solution
One solution
db.accounts_lab.drop()
db.accounts_lab.insertMany([{ _id: "alice", balance: 100 }, { _id: "bob", balance: 50 }])
const s = db.getMongo().startSession()
const accts = s.getDatabase("shop").accounts_lab
s.withTransaction(() => {
  if (accts.updateOne({ _id: "alice", balance: { $gte: 25 } }, { $inc: { balance: -25 } }).modifiedCount !== 1) throw new Error("insufficient funds")
  accts.updateOne({ _id: "bob" }, { $inc: { balance: 25 } })
})
s.endSession()
db.accounts_lab.find().sort({ _id: 1 })

InterviewQuestions you should be able to answer

When should you use multi-document transactions?

When an invariant spans multiple documents and cannot be modelled into one document: transfers between accounts, inventory plus order creation, maintaining a separate uniqueness or ledger collection. Prefer single-document atomic updates when the schema allows, because transactions add latency, contention and abort/retry handling.

What happens when two transactions update the same document?

The first to write holds the document; the second receives a WriteConflict error labelled TransientTransactionError immediately and its transaction is aborted. The driver callback API retries the whole transaction. Conflicts are detected at write time, not at commit (first-writer-wins).

What limits apply to transactions?

Default 60 s lifetime (transactionLifetimeLimitSeconds), the 16 MB limit per oplog entry is avoided since 4.2 by splitting into multiple entries, but large transactions still increase cache pressure and replication lag. Lock acquisition timeout defaults to 5 ms (maxTransactionLockRequestTimeoutMillis). Some operations are not allowed (e.g. writes to capped collections, operations on config/admin/local).

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.