Transactions

MongoDB · Lesson 19 of 28 7 min read Updated

Placing an order in the shop changes two collections: the order is inserted into orders, and the stock of each product goes down in products. If the server crashes or a check fails between those two writes, you could sell stock you do not have, or reduce stock for an order that was never saved. A transaction groups several operations so that either all of them happen or none of them do.

In this lesson you will set up a server that supports transactions, place an order inside one, see how other users cannot see half-finished work, handle failures and learn when you do not need a transaction at all. Start with freshly loaded data and use shop.

Single-document writes are already atomic

Before reaching for transactions, remember that every write to one document is atomic in MongoDB, however many fields, arrays and embedded documents it changes. Inserting an order with five embedded items, or changing a quantity and the total together with one updateOne(), can never be half done. This is a big reason to design documents so that data which changes together lives together, as discussed in Schema Design: Embed or Reference.

Transactions are for the cases that cross document or collection boundaries: an order and the stock of its products, a transfer between two wallets, an invoice and an accounting entry.

ACID in one table

MongoDB's multi-document transactions, available since version 4.0 (and on sharded clusters since 4.2), are ACID:

LetterPropertyIn the shop
AAtomicityThe order insert and stock change both happen, or neither does
CConsistencyValidation rules and unique indexes are respected
IIsolationOther users do not see the new order until it is committed
DDurabilityOnce committed with w: 'majority', the order survives a server failure

Transactions need a replica set

Transactions only work on a replica set or a sharded cluster, because they rely on the replication log. An Atlas cluster, including the Free tier, is always a replica set, so it works straight away. A plain local install is a standalone server and refuses to start a transaction.

For practice, turn your local server into a one-member replica set. Add these lines to the configuration file (mongod.cfg on Windows, /etc/mongod.conf on Linux), restart the service, and run rs.initiate() once in mongosh:

YAML
replication:
  replSetName: rs0

After rs.initiate() the prompt changes to show the set name and the member's role, such as rs0 [direct: primary] test>. A one-member set gives you transactions but no extra safety; real deployments use at least three members, as Replication & Sharding explains.

Placing an order in a transaction

In mongosh you start a session, get the database through the session, and wrap the operations between startTransaction() and commitTransaction(). Putting the steps in a function lets you place several orders. Isha buys one HD Webcam:

MongoDB Shell
function buy(customerId, product, orderNo) {
  const session = db.getMongo().startSession()
  const shop = session.getDatabase('shop')
  session.startTransaction({ readConcern: { level: 'snapshot' }, writeConcern: { w: 'majority' } })
  try {
    const r = shop.products.updateOne({ _id: product._id, stock: { $gte: 1 } }, { $inc: { stock: -1 } })
    if (r.modifiedCount !== 1) throw new Error(product.name + ' is out of stock')
    shop.orders.insertOne({ _id: orderNo, customerId, date: new Date(), status: 'pending', payment: 'UPI',
      items: [{ productId: product._id, name: product.name, price: product.price, qty: 1 }], total: product.price })
    print('inside:', shop.orders.countDocuments(), '| outside:', db.orders.countDocuments())
    session.commitTransaction()
    print('Order placed')
  } catch (e) {
    session.abortTransaction()
    print('Order failed:', e.message)
  } finally {
    session.endSession()
  }
}
buy(8, { _id: 109, name: 'HD Webcam', price: 1899 }, 1013)
Output
inside: 13 | outside: 12
Order placed

Notice the line printed before the commit. Inside the transaction the new order is visible (13 orders), but db, which is not part of the session, still sees 12. That is isolation: nobody else sees a half-placed order. After commitTransaction() both changes become visible to everyone at the same moment.

Only operations made through shop (the session's database) belong to the transaction. A write made through plain db in the middle would happen on its own, outside the transaction, which is a common and confusing mistake.

When the transaction fails

Now call buy(8, { _id: 106, name: 'Laptop Stand', price: 1299 }, 1014). The Laptop Stand's stock is 0, so the conditional updateOne() matches nothing, the code throws, and the catch block aborts:

Output
Order failed: Laptop Stand is out of stock

abortTransaction() throws away every change made inside the transaction, so no order 1014 exists and no stock changed. The stock: { $gte: 1 } condition in the filter is what prevents overselling: two buyers racing for the last webcam cannot both succeed, because the second one's update matches no document.

The server can also abort a transaction by itself:

  • Write conflicts. If another operation changes the same document first, the transaction fails with an error labelled TransientTransactionError. The right reaction is to retry the whole transaction.
  • Timeouts. A transaction that stays open longer than 60 seconds (the default transactionLifetimeLimitSeconds) is aborted automatically.
  • Commit uncertainty. If the network drops during commit, the error is labelled UnknownTransactionCommitResult, and the commit itself can be retried safely.

Writing these retry loops by hand is tedious, so the drivers provide session.withTransaction(), which runs your function, retries on transient errors and commits for you. You will use it in The Node.js Driver.

Rules for good transactions

  • Keep them short. Do slow work, such as calling a payment gateway or sending an SMS, before or after the transaction, never inside it.
  • Keep them small. Change a modest number of documents; a transaction touching many thousands of documents should be redesigned or split.
  • Set concerns on the transaction, not the operations. Read and write concerns go in startTransaction(), as in the example.
  • Prefer document design. If you find a transaction in every write path, ask whether embedding would make most of them unnecessary.

Common mistakes

  • Trying transactions on a standalone server. Use a replica set, even a one-member one, or Atlas.
  • Mixing session and non-session operations. Every operation that should be part of the transaction must go through the session.
  • Forgetting endSession(). Always end sessions, ideally in a finally block.
  • Not retrying. Transient errors are normal under load; retry the transaction, or let withTransaction() do it.

Key points

  • Writes to a single document are always atomic; transactions are for changes across several documents or collections.
  • MongoDB transactions are ACID and need a replica set or sharded cluster (Atlas always qualifies).
  • In mongosh: start a session, use session.getDatabase(), then startTransaction(), commitTransaction() or abortTransaction(), and endSession().
  • Changes inside a transaction are invisible to others until commit, and an abort discards them all.
  • Retry on TransientTransactionError; transactions open longer than 60 seconds are aborted by default.
  • Keep transactions short and small, and prefer schema designs that need them rarely.

Exercise

Write a transaction that cancels pending order 1011 for Aarav: set its status to cancelled and put the Mechanical Keyboard (product 102, quantity 1) back into stock. Abort the transaction if the order is not pending, so the stock is never increased twice.

Show solution
MongoDB Shell
function cancel1011() {
  const session = db.getMongo().startSession()
  const shop = session.getDatabase('shop')
  session.startTransaction({ writeConcern: { w: 'majority' } })
  try {
    const r = shop.orders.updateOne({ _id: 1011, status: 'pending' }, { $set: { status: 'cancelled' } })
    if (r.modifiedCount !== 1) throw new Error('Order 1011 is not pending')
    shop.products.updateOne({ _id: 102 }, { $inc: { stock: 1 } })
    session.commitTransaction()
    print('Order cancelled')
  } catch (e) {
    session.abortTransaction()
    print('Not cancelled:', e.message)
  } finally {
    session.endSession()
  }
}
cancel1011()

The first call prints Order cancelled and the keyboard's stock goes from 45 to 46. A second call finds no pending order 1011, throws, aborts and prints Not cancelled: Order 1011 is not pending, so the stock is not increased again. The status: 'pending' condition in the filter is what makes the operation safe to repeat.

Quick Quiz

1. Which change needs a multi-document transaction to be all-or-nothing?

2. Why does startTransaction() fail on a fresh local install?

3. What should your code do when a transaction fails with a TransientTransactionError?

4. Inside an open transaction you insert an order. What do other clients see?

Learn MongoDB with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call