Multi-document ACID transactions

This section documents the current MongoDB 8.x server line as published at the MongoDB Server Manual, which is the reference these pages are written and verified against. No specific patch version is pinned. Some capabilities (Atlas Search, Atlas Vector Search, and parts of encryption and backup) are Atlas-only — they are linked, not documented in depth.

This content was generated with the assistance of AI and should be verified against the official manual before being relied on in production, since MongoDB iterates quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

A write to a single document in MongoDB is always atomic on its own, even when it touches many fields or several elements of an array. A multi-document transaction is the tool you reach for only when a correctness invariant spans more than one document or more than one collection and every part of it must land, or none of it must.

When a transaction is warranted

Because single-document writes are atomic, the schema often removes the need for a transaction: if the data that changes together lives in one document, one updateOne already gives you all-or-nothing behaviour. Prefer that design (see Data modeling — embed what is updated together) and treat a transaction as the fallback for genuinely cross-document invariants, such as moving a value from one account document to another.

// No transaction needed: the invariant lives in one document.
db.accounts.updateOne(
  { _id: "A" },
  { $inc: { balance: -100 }, $push: { ledger: { ts: new Date(), delta: -100 } } }
)

See Transactions in the manual for the full model and the note that single-document operations are already atomic.

ACID in MongoDB terms

  • Atomicity — on commit, every write in the transaction becomes visible together; on abort, none of them are ever visible.

  • Consistency — schema validation and index constraints (for example a unique index) are enforced for each write in the transaction.

  • Isolation — with readConcern: "snapshot" the transaction reads from a single point-in-time snapshot, so it never sees another transaction’s partial or concurrent work; uncommitted changes are invisible outside the transaction.

  • Durability — with writeConcern: "majority" a committed transaction is acknowledged only once the commit is persisted on a majority of replica-set members.

Client sessions

A transaction always runs inside a client session — an object the driver uses to associate a sequence of operations with the same server-side context. You start a session, pass it to every operation that must be part of the transaction, and end it when done. Operations that do not receive the session are not part of the transaction.

const session = db.getMongo().startSession();
try {
  // ... run the transaction against `session` (see below) ...
} finally {
  session.endSession();
}

withTransaction: the recommended entry point

session.withTransaction(fn) runs the callback inside a transaction and handles the retry logic for you: if the commit fails with a transient transaction error, or the whole transaction fails with an unknown commit result, the driver retries — the callback again, or just the commit — until it succeeds or a non-retryable error is raised. Keep the callback idempotent, since it may run more than once.

const session = db.getMongo().startSession();
try {
  session.withTransaction(() => {
    const accounts = session.getDatabase("bank").accounts;

    accounts.updateOne({ _id: "A" }, { $inc: { balance: -100 } }, { session });
    accounts.updateOne({ _id: "B" }, { $inc: { balance:  100 } }, { session });

    // Throwing here aborts the transaction; the throw propagates out of withTransaction.
    const from = accounts.findOne({ _id: "A" }, { session });
    if (from.balance < 0) {
      throw new Error("insufficient funds");
    }
  }, {
    readConcern:  { level: "snapshot" },
    writeConcern: { w: "majority" }
  });
} finally {
  session.endSession();
}

Explicit start, commit, and abort

The lower-level API is session.startTransaction(options), then session.commitTransaction() or session.abortTransaction(). With this form you own the retry loop — withTransaction exists precisely so you usually do not have to write it.

const session = db.getMongo().startSession();
session.startTransaction({
  readConcern:  { level: "snapshot" },
  writeConcern: { w: "majority" }
});
try {
  const accounts = session.getDatabase("bank").accounts;
  accounts.updateOne({ _id: "A" }, { $inc: { balance: -100 } }, { session });
  accounts.updateOne({ _id: "B" }, { $inc: { balance:  100 } }, { session });
  session.commitTransaction();
} catch (e) {
  session.abortTransaction();
  throw e;
} finally {
  session.endSession();
}

A commit that fails with a TransientTransactionError label can be retried whole; a commit that fails with an UnknownTransactionCommitResult label can have just commitTransaction() retried. See Session.startTransaction(), Session.commitTransaction(), and Session.abortTransaction().

Read and write concern inside a transaction

Read and write concern are set on the transaction as a whole, not per operation — individual operations inside the transaction ignore their own concern settings.

  • readConcern: { level: "snapshot" } gives every read in the transaction a consistent view as of a single cluster time, and (on commit with w: "majority") that snapshot is guaranteed to be from majority-committed data.

  • writeConcern: { w: "majority" } applies to the commit: the transaction is durable once a majority of members have the commit.

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

See Read Concern / Write Concern / Read Preference for transactions, and Replication for how majority acknowledgement works across a replica set.

Limits: keep transactions short

  • A transaction has a 60-second default runtime limit (transactionLifetimeLimitSeconds); it is aborted by the server once it exceeds that, whether or not it is still doing work.

  • The commit produces oplog entries subject to the 16 MB BSON document limit; a transaction that modifies too much data cannot be committed. Very large bulk changes belong outside a transaction.

  • Locks a transaction holds block other writers. By default an operation waits only maxTransactionLockRequestTimeoutMillis (5 ms) to acquire a lock before yielding to the transaction.

The practical guidance is to touch a small number of documents, do no slow application work between the first read and the commit, and split large jobs into many small transactions. See Production Considerations for the full list of runtime and sizing limits.

Distributed transactions on a sharded cluster

The same session API works unchanged on a sharded cluster: a transaction that spans shards is a distributed transaction, coordinated by a two-phase commit that one shard runs as coordinator. It costs more — extra network round trips and coordinator state — so a data model that keeps each transaction’s documents on a single shard (for example by choosing a shard key that co-locates related documents) performs markedly better. See Transactions and Sharding.

Transaction lifecycle

stateDiagram-v2 [*] --> SessionStarted: startSession() SessionStarted --> InProgress: startTransaction() InProgress --> InProgress: reads / writes on the session InProgress --> Committed: commitTransaction() InProgress --> Aborted: abortTransaction() / error / 60s timeout Committed --> SessionStarted: begin another transaction Aborted --> SessionStarted: retry (TransientTransactionError) SessionStarted --> [*]: endSession()

Contrast with SQL

Relational databases expose tunable isolation levels (READ COMMITTED, REPEATABLE READ, SERIALIZABLE, and so on). MongoDB does not offer that dial: a transaction reads at snapshot isolation and that is the only level. For the SQL side of the comparison — BEGIN / COMMIT / ROLLBACK, savepoints, and the standard isolation levels with their anomalies — see Transactions.