Type something to search...
MongoDB Transactions: When and How to Use Multi-Document ACID Transactions

MongoDB Transactions: When and How to Use Multi-Document ACID Transactions

For a long time, "MongoDB doesn't do transactions" was a standard objection in database debates. It hasn't been true since MongoDB 4.0 added multi-document transactions on replica sets, and 4.2 extended them to sharded clusters. Yet teams still get transactions wrong in two opposite ways: some avoid them entirely and end up with half-applied writes after a crash, while others wrap every request in a transaction out of habit from relational databases and pay for it in latency and write conflicts.

Multi-document transactions give you the same guarantees you'd expect from a relational database: a group of reads and writes across documents, collections, and even databases that either all commit or all roll back, with a consistent snapshot of the data while they run. The trick is knowing that MongoDB's document model already makes a single write atomic, so you need transactions far less often than you might think.

This guide covers when a transaction is actually necessary, how to write one correctly with the Node.js and Python drivers, how errors and retries work, the limits you need to design around, and the mistakes that cause most transaction bugs in production.

Single-Document Writes Are Already Atomic

Before reaching for a transaction, remember this: every write to a single document is atomic, no matter how many fields or array elements it touches. This update either applies completely or not at all:

db.orders.updateOne(
  { _id: 5012, status: "pending" },
  {
    $set: { status: "paid", paidAt: new Date() },
    $push: { history: { event: "paid", at: new Date() } },
    $inc: { "totals.paid": 129.0 },
  },
);

Because related data often lives in one document (an order with its line items, a user with their addresses), most business operations are a single write. A conditional filter like status: "pending" also gives you optimistic concurrency for free: if another request already changed the status, matchedCount is 0 and you know to back off.

If you find yourself needing transactions constantly, that's often a signal that the schema splits data that's always read and written together. The patterns in One-to-Many Relationships in MongoDB are worth revisiting before you accept the overhead.

When You Genuinely Need a Transaction

Transactions earn their keep when a single logical operation must change several documents that can't reasonably live together, and a partial result would be wrong rather than just untidy. Typical cases:

  • Moving value between records. Transferring credits from one account to another, where debiting without crediting loses money.
  • Enforcing invariants across collections. Creating an order and decrementing inventory, where overselling is unacceptable.
  • Writing a record plus an outbox entry. Saving a domain change and an event to publish, so the event exists if and only if the change does.
  • Maintaining a uniqueness or count constraint that spans documents, such as "no more than 5 active sessions per user."

If a partial failure can be fixed by retrying or by a background reconciliation job, you might not need a transaction. If it would leave data in a state your application must never observe, you do.

Your First Transaction in Node.js

Transactions run inside a client session. Every operation that should be part of the transaction must receive that session. The Node.js driver (6.x) provides withTransaction, which handles starting, committing, and retrying for you:

import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.MONGODB_URI);
const db = client.db("bank");
const accounts = db.collection("accounts");
const ledger = db.collection("ledger");

async function transfer(fromId, toId, amount) {
  await client.withSession(async (session) => {
    await session.withTransaction(async () => {
      const debit = await accounts.updateOne(
        { _id: fromId, balance: { $gte: amount } },
        { $inc: { balance: -amount } },
        { session },
      );
      if (debit.modifiedCount !== 1) {
        throw new Error("Insufficient funds or unknown account");
      }

      await accounts.updateOne(
        { _id: toId },
        { $inc: { balance: amount } },
        { session },
      );

      await ledger.insertOne(
        { fromId, toId, amount, at: new Date() },
        { session },
      );
    });
  });
}

await transfer("acc_alice", "acc_bob", 250);

Here's what happens:

  1. client.withSession creates a session and ends it when the callback finishes.
  2. session.withTransaction starts a transaction, runs your callback, and commits.
  3. If the callback throws, the transaction is aborted and nothing is written. The debit, credit, and ledger entry disappear together.
  4. If the commit hits a transient error (a failover, a write conflict), withTransaction retries the whole callback automatically.

The balance: { $gte: amount } filter matters. The transaction guarantees atomicity, but it doesn't check your business rules. You still need to verify that the debit actually happened before continuing.

Transaction Options

By default a transaction inherits read and write concerns from the client. For operations that must be durable and read a consistent snapshot, set them explicitly:

await session.withTransaction(
  async () => {
    /* operations with { session } */
  },
  {
    readConcern: { level: "snapshot" },
    writeConcern: { w: "majority" },
    readPreference: "primary",
  },
);
  • readConcern: "snapshot" means every read in the transaction sees data as of a single point in time, even across shards.
  • writeConcern: { w: "majority" } means the commit is acknowledged only after a majority of replica set members have it, so a failover can't roll it back.
  • readPreference: "primary" is required. Transactions containing reads must read from the primary.

You can also set these once with client.startSession({ defaultTransactionOptions: { ... } }) so every transaction on that session uses them.

Transactions in Python

PyMongo 4.x mirrors the same design. with_transaction takes a callback that receives the session:

from pymongo import MongoClient, WriteConcern, ReadPreference
from pymongo.read_concern import ReadConcern

client = MongoClient("mongodb://localhost:27017/?replicaSet=rs0")
db = client.shop


def place_order(session, order):
    for item in order["items"]:
        result = db.inventory.update_one(
            {"sku": item["sku"], "available": {"$gte": item["qty"]}},
            {"$inc": {"available": -item["qty"], "reserved": item["qty"]}},
            session=session,
        )
        if result.modified_count != 1:
            raise ValueError(f"Out of stock: {item['sku']}")

    db.orders.insert_one(order, session=session)


order = {
    "_id": "ord_9001",
    "customerId": "c_77",
    "items": [{"sku": "LAMP-BR", "qty": 1}, {"sku": "BULB-E27", "qty": 4}],
    "status": "placed",
}

with client.start_session() as session:
    session.with_transaction(
        lambda s: place_order(s, order),
        read_concern=ReadConcern("snapshot"),
        write_concern=WriteConcern("majority"),
        read_preference=ReadPreference.PRIMARY,
    )

If any SKU is out of stock, the ValueError aborts the transaction, and the inventory reserved for earlier items in the loop is rolled back too. That's exactly the "all or nothing" behavior that would be painful to build by hand.

With PyMongo's async API (AsyncMongoClient), the shape is the same with async with and await session.with_transaction(...), where the callback is a coroutine function.

How Errors and Retries Work

Transaction failures fall into categories, and the driver labels errors so you can tell them apart:

Error labelMeaningRight response
TransientTransactionErrorThe transaction failed but may succeed if run againRetry the entire transaction
UnknownTransactionCommitResultThe commit may or may not have applied (network blip)Retry the commit, not the whole thing
No labelA real error: validation failure, duplicate key, your throwAbort and surface the error

withTransaction implements this logic for you. It retries the callback on TransientTransactionError, retries the commit on UnknownTransactionCommitResult, and keeps trying for up to 120 seconds before giving up. That's the main reason to prefer the callback API over manually calling startTransaction() and commitTransaction().

The most common transient error is a write conflict. Transactions in MongoDB use optimistic concurrency: if two transactions modify the same document, the second one to write gets a WriteConflict error with the transient label. withTransaction retries it, which usually succeeds once the other transaction finishes.

Keep Side Effects Out of the Callback

Because the callback can run more than once, anything inside it that isn't a database operation on the session will also run more than once:

// Wrong: the email may be sent several times, or sent for a rolled-back order
await session.withTransaction(async () => {
  await orders.insertOne(order, { session });
  await sendConfirmationEmail(order); // side effect inside the transaction
});

// Right: act after the commit succeeds
await session.withTransaction(async () => {
  await orders.insertOne(order, { session });
  await outbox.insertOne(
    { type: "order.placed", orderId: order._id },
    { session },
  );
});
// a separate worker reads the outbox and sends the email

The outbox pattern in the second example is one of the best uses of transactions: the event is recorded atomically with the change, and delivery happens reliably afterwards.

Limits You Need to Design Around

Transactions are powerful but not free, and MongoDB enforces limits that shape how you use them.

Time limit. By default, a transaction that runs longer than 60 seconds is aborted automatically. The server parameter is transactionLifetimeLimitSeconds. You can raise it on self-managed clusters, but a transaction that needs more than a few seconds is usually a design problem.

Cache pressure. While a transaction is open, WiredTiger has to keep the snapshot it started from, along with every change it made. Long or large transactions increase cache pressure for the whole server, which slows down unrelated operations.

Size. There's no hard cap on the number of documents, but MongoDB's own guidance is to keep transactions to around 1,000 documents or fewer. For bulk changes, split the work into batches that are each safe to apply on their own.

Locks on DDL. Creating a collection or index inside a transaction is allowed in recent versions, with restrictions, but a transaction holds locks that can block DDL operations like createIndex or drop on the same collection. Avoid mixing schema changes with transactional traffic.

Sharded transactions cost more. A transaction that touches a single shard is nearly as cheap as one on a replica set. A transaction that spans shards uses a two-phase commit coordinated by one of the shards, which adds network round trips. Choose shard keys so that the documents you update together live on the same shard whenever possible.

Replica set or sharded cluster only. Transactions don't work on a standalone mongod. For local development, run a single-node replica set:

mongod --replSet rs0 --dbpath ./data --port 27017
mongosh --eval 'rs.initiate()'

Using Transactions with Mongoose

Mongoose 8 wraps the driver's session API. The simplest form is connection.transaction(), which calls withTransaction under the hood and resets document state properly if a retry happens:

import mongoose from "mongoose";

await mongoose.connection.transaction(async (session) => {
  const account = await Account.findOne({ _id: fromId }).session(session);
  if (!account || account.balance < amount) {
    throw new Error("Insufficient funds");
  }
  account.balance -= amount;
  await account.save({ session });

  await Account.updateOne(
    { _id: toId },
    { $inc: { balance: amount } },
    { session },
  );
});

Mongoose also has an option to propagate the session automatically through AsyncLocalStorage, so you don't have to pass { session } to every call. It's convenient, but explicit sessions make it obvious which operations are transactional, which helps in code review.

Common Mistakes

Forgetting to pass the session. This is the number one transaction bug. An operation without { session } runs outside the transaction: it commits immediately, it isn't rolled back on abort, and it can't see the transaction's uncommitted writes. Code review every call inside the callback, or wrap your collections in a helper that requires a session.

Wrapping every request in a transaction. Transactions add round trips, hold snapshots in cache, and create write conflicts under contention. Use them for operations that must be atomic across documents, and use single-document atomic updates for everything else.

Doing slow work inside the transaction. Calling external APIs, waiting on user input, or running heavy computation while a transaction is open keeps the snapshot pinned and increases the chance of conflicts and timeouts. Gather what you need first, then open the transaction, write, and commit quickly.

Running operations in parallel on one session. A session is not safe for concurrent use. Firing off several operations with Promise.all inside a transaction leads to errors or undefined ordering. Await each operation in sequence.

Ignoring UnknownTransactionCommitResult in manual code. If you use startTransaction() and commitTransaction() directly, you must handle both error labels yourself. Without that logic, a network hiccup during commit can make you report failure for a transaction that actually succeeded, or retry a transfer that already happened.

Reading your own writes from a different session. Writes inside an uncommitted transaction are invisible to everyone else, including other code paths in your own request that don't use the session. Keep all related reads and writes on the same session until the commit.

A Quick Decision Checklist

Before adding a transaction, ask:

  1. Can I restructure the data so this is a single-document update? If yes, do that.
  2. Would a partial failure be visible and harmful to users? If no, consider retries or reconciliation.
  3. Does the operation finish in well under a second and touch a bounded number of documents? If no, split it.
  4. Are all the documents on the same shard (or is the cluster unsharded)? If not, expect extra latency.

If you pass all four, a transaction is the right tool, and withTransaction with majority write concern is the right way to write it.

Conclusion

MongoDB's multi-document transactions provide full ACID guarantees across documents, collections, and shards. Most writes don't need them because single-document updates are already atomic, but for transfers, cross-collection invariants, and outbox events they're the cleanest solution available. Use the callback API (withTransaction) so retries are handled correctly, pass the session to every operation, keep side effects outside the callback, and keep transactions short and small.

Your next step: search your codebase for places where two or more writes happen back to back in one request handler. For each one, decide whether a partial failure would be harmful. Wrap the ones that would in withTransaction, and leave the rest as simple, fast single-document writes.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading