Multi-Document Transactions

A MongoDB transaction lets you group multiple read and write operations — even across several collections or databases — into a single all-or-nothing unit. Either every operation inside the transaction succeeds and is made durable together, or none of them are applied at all. This matters whenever your application logic depends on more than one document changing consistently, such as moving money between two account documents or decrementing inventory while creating an order.

Overview / How it works

Every single write to a single document in MongoDB is already atomic, with or without a transaction — a call like updateOne() that changes several fields inside one document either applies fully or not at all. Multi-document transactions exist for the case a single-document write can’t cover: when correctness requires two or more documents (in the same collection, different collections, or even different databases within the same replica set) to change together as one unit, with a guarantee that no other client ever observes a half-applied state.

A transaction is opened on a session. In mongosh you get a session with db.getMongo().startSession(), and every operation that should participate in the transaction is issued through that session object (or with an explicit { session } option when using the Node.js driver). Internally, MongoDB associates every operation inside the transaction with the session’s logical clock and buffers the writes; other clients reading with the default read concern continue to see the pre-transaction state of the affected documents until the transaction commits. If you set readConcern: { level: "snapshot" }, all reads inside the transaction also see a single consistent point-in-time snapshot of the data, so a read later in the transaction can’t see a write made by some other transaction that started after yours.

On commitTransaction(), the server applies all the buffered operations as a single, durably-replicated unit — the underlying oplog entries are written so the whole group is either fully present or fully absent on every node that has applied up to that point. If the transaction spans multiple shards, the commit is coordinated with a two-phase commit protocol: it is more expensive than a single-shard transaction, which is one reason to keep the documents touched by a transaction as close together (ideally one shard) as possible.

Transactions are not free. MongoDB holds locks and buffers state for the transaction’s lifetime, so a transaction is aborted automatically if it runs longer than transactionLifetimeLimitSeconds (60 seconds by default). A transaction touching many documents also increases pressure on the WiredTiger storage engine’s cache, since none of the buffered changes are visible or reclaimable until commit or abort. Use transactions surgically — they are the right tool for a small, tightly-scoped group of writes that must succeed or fail together, not a general substitute for good schema design.

Syntax

The core transaction API in mongosh looks like this:

const session = db.getMongo().startSession();

session.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
});

try {
  session.getDatabase("myDb").myCollection.updateOne(
    { status: "pending" },
    { $set: { status: "processed" } }
  );

  session.commitTransaction();
} catch (error) {
  session.abortTransaction();
  throw error;
} finally {
  session.endSession();
}

Options accepted by startTransaction():

Option Meaning
readConcern.level "local", "majority", or "snapshot""snapshot" guarantees every read inside the transaction sees one consistent point-in-time view.
writeConcern Applied when the transaction commits, e.g. { w: "majority" }, requiring the commit be acknowledged by a majority of replica set members.
maxCommitTimeMS Caps how long commitTransaction() is allowed to take before giving up.

Instead of writing the try/catch/finally scaffolding yourself, most real code uses the withTransaction() helper, available on the session object in both mongosh and the Node.js driver: session.withTransaction(callback, options) runs your callback, commits automatically when it returns normally, aborts automatically if it throws, and — importantly — retries the whole callback automatically if the server reports a retryable transaction error.

Examples

Example 1: Transferring money between two accounts

Suppose a bank database has an accounts collection where account A123 and account B456 each currently hold a balance of 500. Transferring 100 from A123 to B456 requires two updates that must either both happen or neither happen — if the debit succeeded but the credit failed, money would simply vanish.

use bank
const session = db.getMongo().startSession();
const accounts = session.getDatabase("bank").accounts;

session.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
});

try {
  accounts.updateOne({ _id: "A123" }, { $inc: { balance: -100 } });
  accounts.updateOne({ _id: "B456" }, { $inc: { balance: 100 } });
  session.commitTransaction();
  print("Transfer committed");
} catch (error) {
  session.abortTransaction();
  throw error;
} finally {
  session.endSession();
}

Output:

Transfer committed

Both updates ran through the same session, so MongoDB treated them as one unit; commitTransaction() only returns once both changes are durably applied together. Checking the accounts afterward confirms it:

db.accounts.find({ _id: { $in: ["A123", "B456"] } });
[
  { _id: 'A123', balance: 400 },
  { _id: 'B456', balance: 600 }
]

Example 2: Aborting a transaction when a business rule fails

Continuing from Example 1, account A123 now holds 400. Suppose a customer requests a 500 transfer out of it — more than the account actually has. The transaction reads the current balance through the session, checks it against the business rule, and manually aborts if the rule fails, rather than letting a partial transfer through:

const session = db.getMongo().startSession();
const accounts = session.getDatabase("bank").accounts;

session.startTransaction({ writeConcern: { w: "majority" } });

try {
  const sender = accounts.findOne({ _id: "A123" });

  if (sender.balance < 500) {
    throw new Error("Insufficient funds");
  }

  accounts.updateOne({ _id: "A123" }, { $inc: { balance: -500 } });
  accounts.updateOne({ _id: "B456" }, { $inc: { balance: 500 } });

  session.commitTransaction();
} catch (error) {
  session.abortTransaction();
  print("Transaction aborted: " + error.message);
} finally {
  session.endSession();
}
Transaction aborted: Insufficient funds

Because abortTransaction() ran before any write was committed, neither updateOne() call took effect — both accounts are exactly as they were before this transaction started, even though the code had already “sent” the debit and credit to the server.

Example 3: Placing an order with withTransaction()

A more realistic app flow touches two collections at once: creating a document in orders and decrementing stock in products. Suppose a shop database has a product sku-42 with 3 units in stock. Using withTransaction() removes the manual try/catch/retry scaffolding — you write the operations, and MongoDB commits on success or aborts (and retries transient errors) automatically:

use shop
const session = db.getMongo().startSession();
const products = session.getDatabase("shop").products;
const orders = session.getDatabase("shop").orders;

session.withTransaction(() => {
  const product = products.findOne({ _id: "sku-42" });

  if (product.stock < 1) {
    throw new Error("Out of stock");
  }

  products.updateOne({ _id: "sku-42" }, { $inc: { stock: -1 } });
  orders.insertOne({
    productId: "sku-42",
    quantity: 1,
    status: "placed",
    createdAt: new Date()
  });

  print("Order placed");
}, {
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
});

session.endSession();
Order placed

Checking both collections shows the stock decremented and the order recorded, as a single committed unit:

db.products.findOne({ _id: "sku-42" });
db.orders.findOne({ productId: "sku-42" });
{ _id: 'sku-42', name: 'Wireless Mouse', stock: 2 }
{
  _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
  productId: 'sku-42',
  quantity: 1,
  status: 'placed',
  createdAt: ISODate("2026-08-03T00:00:00.000Z")
}

How it works step by step

Here is what actually happens when you run a transaction like the ones above:

  1. Start: startSession() allocates a session with its own logical/cluster time. startTransaction() marks that session as “in a transaction” and records the read concern and write concern that will apply to it.
  2. Buffered writes: each write issued through the session (via session.getDatabase(...) in mongosh, or the { session } option with the Node.js driver) is tagged with the transaction’s identifiers. The server takes the same per-document locks it would for a normal write, but the changes are not visible to any other session — even one reading the same document with default read concern — until the transaction commits.
  3. Snapshot reads: with readConcern: { level: "snapshot" }, every read inside the transaction is pinned to the same point-in-time snapshot established when the transaction started, so a later read in the same transaction can’t suddenly see a write some other concurrent transaction committed in between.
  4. Conflict detection: if a concurrent transaction (or a normal write) modifies a document your transaction has also touched, the storage engine raises a write conflict. MongoDB surfaces this to the driver as an error labeled TransientTransactionError — the whole transaction must be retried from the start, since there is no way to selectively “redo” one operation inside it.
  5. Commit: commitTransaction() asks every shard/node involved to durably apply the buffered writes as a single unit, replicate them, and satisfy the requested write concern. On a single replica set this is one round of oplog replication; across shards, a transaction coordinator runs a two-phase commit so that either every shard applies its part or none do.
  6. Ambiguous commits: if the network drops the response to a commit request, the driver doesn’t know whether the commit actually succeeded on the server. This is reported as UnknownTransactionCommitResult, and the safe response is to retry just the commit (not the whole transaction), since retrying a commit that already succeeded is a safe no-op.
  7. Abort: abortTransaction() — called explicitly, thrown from inside a withTransaction() callback, or triggered by the 60-second default lifetime limit — discards every buffered write; no other session ever saw them, so there is nothing to roll back from an outside observer’s point of view.

Common Mistakes

Mistake 1: Forgetting to route every operation through the session

Only writes issued through the session-bound database handle actually join the transaction. Calling the plain db object instead silently runs the operations outside the transaction entirely:

const session = db.getMongo().startSession();
session.startTransaction();

db.accounts.updateOne({ _id: "A123" }, { $inc: { balance: -100 } });
db.accounts.updateOne({ _id: "B456" }, { $inc: { balance: 100 } });

session.commitTransaction();
session.endSession();

Both updateOne() calls here go through db, not the session, so they commit immediately and independently — if the second one fails for any reason, the first has already applied, which is exactly the inconsistency transactions exist to prevent. Fix it by getting the collection handle from the session:

const session = db.getMongo().startSession();
const accounts = session.getDatabase("bank").accounts;

session.startTransaction();
accounts.updateOne({ _id: "A123" }, { $inc: { balance: -100 } });
accounts.updateOne({ _id: "B456" }, { $inc: { balance: 100 } });
session.commitTransaction();
session.endSession();

Mistake 2: Not handling retryable transaction errors

Under concurrent load, MongoDB can abort a transaction internally with a write conflict (TransientTransactionError), or a commit can time out on the network without the client knowing whether it actually applied (UnknownTransactionCommitResult). Treating every failure as final and simply re-throwing loses transactions that a retry would have completed successfully:

session.startTransaction();
try {
  accounts.updateOne({ _id: "A123" }, { $inc: { balance: -100 } });
  accounts.updateOne({ _id: "B456" }, { $inc: { balance: 100 } });
  session.commitTransaction();
} catch (error) {
  session.abortTransaction();
  throw error;
}

Rather than hand-writing retry loops around TransientTransactionError and UnknownTransactionCommitResult, use withTransaction(), which already retries both cases for you:

const session = db.getMongo().startSession();
const accounts = session.getDatabase("bank").accounts;

session.withTransaction(() => {
  accounts.updateOne({ _id: "A123" }, { $inc: { balance: -100 } });
  accounts.updateOne({ _id: "B456" }, { $inc: { balance: 100 } });
});

session.endSession();

Mistake 3: Wrapping an already-atomic single-document write in a transaction

A single updateOne() call against one document is already atomic, transaction or not. Wrapping it in startTransaction()/commitTransaction() adds session overhead and an extra network round trip for zero additional safety:

const session = db.getMongo().startSession();
const carts = session.getDatabase("shop").carts;

session.startTransaction();
carts.updateOne(
  { _id: "cart-1" },
  { $push: { items: { sku: "sku-42", qty: 1 } }, $inc: { itemCount: 1 } }
);
session.commitTransaction();
session.endSession();

Both the $push and the $inc happen inside the same single-document write, which MongoDB already guarantees is all-or-nothing. Just call it directly:

db.carts.updateOne(
  { _id: "cart-1" },
  { $push: { items: { sku: "sku-42", qty: 1 } }, $inc: { itemCount: 1 } }
);

Best Practices

  • Keep transactions short-lived and touch as few documents, collections, and shards as possible — commit or abort quickly, and never do slow application logic (like an HTTP call) between operations inside one.
  • Always route every operation that must participate through the session (session.getDatabase(...) in mongosh, or the { session } option with the Node.js driver) — an operation issued without it silently runs outside the transaction.
  • Prefer withTransaction() over manually calling startTransaction()/commitTransaction()/abortTransaction() — it retries TransientTransactionError and UnknownTransactionCommitResult for you.
  • Use writeConcern: { w: "majority" } on commit so the durability guarantee matches the rest of a production deployment.
  • Reach for a transaction only when correctness genuinely requires multiple documents to change atomically together; prefer schema design (embedding related data in one document) that avoids needing one at all.
  • Index the fields your transaction’s operations filter by — a transaction that triggers a collection scan holds its locks and snapshot open longer, increasing the chance of conflicting with other transactions.
  • Avoid transactions that span many shards when you can restructure the shard key or schema so the documents involved live on one shard.

Practice Exercises

  1. Write a transaction that transfers a book between two library branch documents in a branches collection, decrementing copies on the source branch and incrementing it on the destination; manually abort with a clear error message if the source has zero copies.
  2. Take the fund-transfer transaction from Example 1 and rewrite it using withTransaction() instead of the manual try/catch/finally scaffolding. Confirm it produces the same balances.
  3. Using withTransaction(), write a “place an order” flow that inserts into orders and decrements stock in products, then deliberately throw an error inside the callback and verify with find() that neither the order nor the stock change was applied.

Summary

  • Multi-document transactions let a group of reads/writes across documents, collections, or databases commit or abort as one atomic unit.
  • Single-document writes are already atomic; reach for a transaction only when multiple documents must change together.
  • Sessions carry transaction state; every operation that should be part of the transaction must be issued through the session.
  • readConcern: "snapshot" gives a consistent point-in-time view of reads inside the transaction; writeConcern on commit controls durability.
  • withTransaction() is the recommended API — it commits, aborts on error, and retries transient errors automatically.
  • Keep transactions short and narrow in scope; the default lifetime limit is 60 seconds, and long or large transactions add cache pressure and increase conflict risk.
  • Cross-shard transactions use two-phase commit and cost more than single-shard ones — design shard keys and schemas to minimize how often that is necessary.