Transactions

Keep related changes consistent across several documents and collections with multi-document transactions: start a session, run operations inside withTransaction, commit or abort, handle retries, and know when single-document atomicity is enough.

Lesson 18 of 43Advanced19 min read

Some changes must happen all together or not at all. Transferring money takes from one account and adds to another; if the program crashes in between, money vanishes. Placing an order creates the order and reduces stock; if only one happens, the shop sells items it doesn't have or loses track of orders.

In MongoDB, a write to a single document is always atomic, even when it changes many fields and arrays inside it. That's why good schema design, which keeps related data in one document, often removes the need for anything more. When a change must span several documents or collections, MongoDB offers multi-document transactions with full ACID guarantees: all the changes are committed together, or none are, and other operations never see half-finished work.

Transactions need a replica set (or a sharded cluster), which every Atlas cluster is. In this lesson you'll run transactions with sessions, use withTransaction to commit and retry automatically, abort when a business rule fails, and learn the costs and limits that make transactions a tool to use deliberately rather than everywhere.

Sessions and withTransaction

MongoDB Shell
const session = db.getMongo().startSession()
const accounts = session.getDatabase('bank').accounts

session.withTransaction(() => {
  accounts.updateOne({ _id: 'ada' }, { $inc: { balance: -50 } })
  accounts.updateOne({ _id: 'ben' }, { $inc: { balance: 50 } })
})
session.endSession()
  • A session groups operations. Every operation inside the transaction must use the session, here through session.getDatabase(...); in drivers, pass { session } to each call.
  • withTransaction starts the transaction, runs your function, and commits if it finishes. If it throws, the transaction is aborted and none of its writes are kept.
  • It also retries automatically when MongoDB reports a temporary error, such as a write conflict with another transaction.
  • Manual control is available with startTransaction(), commitTransaction() and abortTransaction().

ACID in MongoDB

  • Atomicity: all the transaction's writes are applied, or none are.
  • Consistency: rules such as unique indexes and validation are checked for every write.
  • Isolation: other operations don't see the transaction's changes until it commits (snapshot isolation).
  • Durability: with write concern majority, committed data survives the failure of a server.

Transactions should be short. By default they time out after 60 seconds, and long transactions hold resources and cause conflicts with other writes.

Aborting on business rules

Throw inside the transaction function when a rule fails, and everything is rolled back:

MongoDB Shell
session.withTransaction(() => {
  const from = accounts.findOne({ _id: 'ada' })
  if (from.balance < 50) throw new Error('Insufficient funds')
  // ... both updates
})

A safer pattern puts the condition in the update filter, updateOne({ _id: 'ada', balance: { $gte: 50 } }, ...), and aborts if modifiedCount is 0, so the check and the change happen together.

Write conflicts and what transactions can't do

Two transactions that change the same document at the same time can't both win. The second one to write gets a WriteConflict error straight away, instead of waiting, and the error carries the label TransientTransactionError:

MongoDB Shell
s1.startTransaction(); s2.startTransaction()
a1.updateOne({ _id: 'ada' }, { $inc: { balance: -10 } })   // session 1 writes first
a2.updateOne({ _id: 'ada' }, { $inc: { balance: -20 } })   // session 2: WriteConflict

That label means "this transaction failed, but running the whole thing again may succeed". withTransaction checks for it and retries your function automatically, which is the main reason to prefer it over calling startTransaction and commitTransaction yourself. If you manage transactions manually, you need your own retry loop.

Inside a transaction, your reads see your own uncommitted changes, but nobody else does. A balance of 0 updated to 5 reads as 5 inside the session and still 0 from any other connection until the commit.

A few operations aren't allowed inside a transaction. You can't create an index on an existing collection, for example; that fails with "Cannot create new indexes on existing collection ... in a multi-document transaction". Creating a new collection by inserting into it does work in MongoDB 4.4 and later. Do schema changes such as indexes before the transaction, as part of your deployment, rather than inside it.

Transactions also need a replica set or sharded cluster. On a single standalone server, used by many local development setups, starting one fails, so run a single-member replica set locally if your code depends on them.

MongoDB Advanced Syntax

Transaction syntax

const s = db.getMongo().startSession()
const coll = s.getDatabase('shop').orders
s.withTransaction(() => {
  coll.insertOne({ ... })
  s.getDatabase('shop').stock.updateOne({ ... }, { ... })
})
s.endSession()

// manual
s.startTransaction({ readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } })
try { /* ... */ s.commitTransaction() } catch (e) { s.abortTransaction(); throw e }

MongoDB Advanced Summary

  • Writes to a single document are always atomic; multi-document transactions cover several documents.
  • Transactions need a replica set or sharded cluster and run inside a session.
  • withTransaction commits when the function completes, aborts when it throws, and retries temporary errors.
  • Every operation in the transaction must use the session.
  • Keep transactions short, and prefer schema designs that avoid them where possible.

MongoDB Advanced Examples

Each example shows the code, the output it produces and what each part does.

Example 1

A money transfer

MongoDB Shell
db.accounts.insertMany([{ _id: 'ada', balance: 120 }, { _id: 'ben', balance: 30 }])

function transfer(from, to, amount) {
  const session = db.getMongo().startSession()
  const accounts = session.getDatabase(db.getName()).accounts
  try {
    session.withTransaction(() => {
      const debit = accounts.updateOne({ _id: from, balance: { $gte: amount } }, { $inc: { balance: -amount } })
      if (debit.modifiedCount === 0) throw new Error(`${from} has insufficient funds`)
      accounts.updateOne({ _id: to }, { $inc: { balance: amount } })
    })
    return `moved ${amount} from ${from} to ${to}`
  } catch (e) {
    return `cancelled: ${e.message}`
  } finally {
    session.endSession()
  }
}

transfer('ada', 'ben', 50)
transfer('ben', 'ada', 500)
db.accounts.find().sort({ _id: 1 })
Output
{ acknowledged: true, insertedIds: { '0': 'ada', '1': 'ben' } }
moved 50 from ada to ben
cancelled: ben has insufficient funds
[ { _id: 'ada', balance: 70 }, { _id: 'ben', balance: 80 } ]
Line-by-line explanation
  1. Lines 4 to 5The session's database handle makes every operation part of the transaction.
  2. Line 8The balance check is part of the update filter, so check and change happen atomically.
  3. Line 9Throwing aborts the transaction, so neither account changes.
  4. Line 10The credit only commits together with the debit.
  5. Line 16endSession always runs, releasing the session.
Example 2

An order and stock change together

MongoDB Shell
db.stock.insertMany([{ _id: 'MUG', qty: 3 }, { _id: 'LAMP', qty: 1 }])

function placeOrder(orderId, lines) {
  const session = db.getMongo().startSession()
  const shop = session.getDatabase(db.getName())
  try {
    session.withTransaction(() => {
      shop.orders.insertOne({ _id: orderId, lines })
      for (const { sku, qty } of lines) {
        const r = shop.stock.updateOne({ _id: sku, qty: { $gte: qty } }, { $inc: { qty: -qty } })
        if (r.modifiedCount === 0) throw new Error(`not enough ${sku}`)
      }
    })
    return `order ${orderId} placed`
  } catch (e) {
    return `order ${orderId} failed: ${e.message}`
  } finally {
    session.endSession()
  }
}

placeOrder(1, [{ sku: 'MUG', qty: 2 }])
placeOrder(2, [{ sku: 'MUG', qty: 1 }, { sku: 'LAMP', qty: 2 }])
db.stock.find().sort({ _id: 1 })
db.orders.countDocuments()
Output
{ acknowledged: true, insertedIds: { '0': 'MUG', '1': 'LAMP' } }
order 1 placed
order 2 failed: not enough LAMP
[ { _id: 'LAMP', qty: 1 }, { _id: 'MUG', qty: 1 } ]
1
Line-by-line explanation
  1. Line 8The order is inserted inside the transaction.
  2. Line 10Each line reduces stock only if enough is available.
  3. Line 11One failing line throws and aborts the whole transaction.
  4. Line 23Order 2's mug reduction and its insert were both rolled back, so only one order and the right stock remain.
Example 3

When one document is enough

MongoDB Shell
db.carts.insertOne({ _id: 'cart-ada', lines: [], total: 0 })

function addLine(sku, price, qty) {
  return db.carts.updateOne(
    { _id: 'cart-ada' },
    { $push: { lines: { sku, price, qty } }, $inc: { total: price * qty } }
  ).modifiedCount
}

addLine('MUG', 8.5, 2)
addLine('PEN', 1.2, 5)
db.carts.findOne({ _id: 'cart-ada' }, { _id: 0, total: 1, lineCount: { $size: '$lines' } })
Output
{ acknowledged: true, insertedId: 'cart-ada' }
1
1
{ total: 23, lineCount: 2 }
Line-by-line explanation
  1. Line 5The new line and the running total change in a single update to a single document.
  2. Line 5Single-document updates are atomic, so the total always matches the lines without any transaction.

Common MongoDB Advanced Mistakes

Forgetting to use the session

Don't
session.withTransaction(() => {
  db.accounts.updateOne({ _id: 'ada' }, { $inc: { balance: -50 } })   // not in the transaction
})
Do
const accounts = session.getDatabase('bank').accounts
session.withTransaction(() => {
  accounts.updateOne({ _id: 'ada' }, { $inc: { balance: -50 } })
})

Long-running transactions

Don't
session.withTransaction(() => {
  // updates 2 million documents and calls an external API
})
Do
// keep transactions to a few quick writes; do batch work in small chunks outside transactions

Using transactions instead of better design

Don't
// order header and order lines in two collections, updated in a transaction on every change
Do
{ _id: 7, status: 'paid', lines: [ /* embedded */ ] }   // one atomic document

MongoDB Advanced in Real Projects

Transactions in practice

Banking, payments, ticketing and inventory systems use transactions when several independent records must change together, such as moving money between accounts or reserving seats across shows. Node.js apps run them with the driver's session.withTransaction(), as shown in the Node.js driver lesson. Most other features rely on single-document atomicity, which is faster and simpler.

MongoDB Advanced FAQ

Does MongoDB support ACID transactions?

Yes. Single-document writes have always been atomic, and since MongoDB 4.0 multi-document transactions on replica sets give atomicity, consistency, snapshot isolation and durability across several documents and collections. Sharded clusters support them from MongoDB 4.2. They're used for things like transfers between accounts, where several documents must change together.

Why do I get "Transaction numbers are only allowed on a replica set member or mongos"?

Your server is running as a standalone instance, and transactions need a replica set or sharded cluster. mongosh may report the same problem as "This MongoDB deployment does not support retryable writes". For local development, start mongod with --replSet rs0 and run rs.initiate() once to create a single-member replica set. MongoDB Atlas clusters are always replica sets, so transactions work there without changes.

What is TransientTransactionError in MongoDB?

It's an error label meaning the transaction failed for a temporary reason, such as a write conflict with another transaction or a brief loss of the primary, and the whole transaction can safely be retried. withTransaction in mongosh and the drivers retries it for you. If you write your own transaction logic, catch errors with this label and run the transaction again from the start.

How long can a MongoDB transaction run?

By default, a transaction is aborted if it runs longer than 60 seconds, controlled by the server setting transactionLifetimeLimitSeconds. Aim for transactions that take milliseconds: do your reading, calculating and validation before you start, and keep only the writes inside. Long transactions hold resources and make write conflicts with other operations more likely.

Do I need a transaction to update one document?

No. Every write to a single document is atomic on its own, including updates that change several fields, push to arrays and increment counters in one operation. That's why embedding related data, such as an order's items inside the order, often removes the need for transactions entirely.

MongoDB Advanced Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the call that runs and commits a transaction.

session.(() => {
  coll.insertOne({ _id: 1 })
})
Multiple choiceeasy

Is an update that changes several fields of one document atomic?

Multiple choicemedium

What happens if the function passed to withTransaction throws?

Multiple choicemedium

What do transactions require?

Multiple choicehard

Why put the balance check in the update filter rather than reading first?

MongoDB Advanced Quiz

5 questions to check what you learned.

  1. What does the A in ACID stand for?

    • Availability
    • Accuracy
    • Aggregation
    • Atomicity
  2. What must every operation inside a transaction use?

    • The transaction's session
    • An index
    • A projection
    • A write concern of 1
  3. What's the default time limit for a transaction?

    • 1 second
    • 1 hour
    • 60 seconds
    • No limit
  4. What does withTransaction do with temporary errors such as write conflicts?

    • Ignores them
    • Retries the transaction automatically
    • Crashes the shell
    • Commits anyway
  5. When is a multi-document transaction most appropriate?

    • Updating one order's status
    • Adding a tag to a post
    • Incrementing a view counter
    • Moving money between two separate account documents