Filter concepts by levelShowing all levels.

MongoDB · Transactions

Transactions

Extending single-document atomicity across documents: lifecycle, cost, retries, and when the trade-off is worth it

  • Single doc: freeone write call to one document is always atomic
  • Multi-doc: opt-instartTransaction → { session } writes → commit/abort
  • Boundary = costlocks and snapshot resources held for the whole open duration
  • Some errors are expectedTransientTransactionError means retry, not "broken"

1.Single-document atomicity

A write to one document — even one that touches several nested fields and array elements at once — is always all-or-nothing. No other operation ever sees the document half-updated, and multi-document transactions exist for exactly the cases this guarantee does not cover.

Key features

  • A single updateOne/updateMany/findOneAndUpdate call is atomic per document it touches
  • This holds even when the update modifies multiple fields, nested subdocuments, and array elements in one call
  • No reader ever observes a document mid-update — a read sees either the fully-old or fully-new version
  • This guarantee does NOT extend across two separate write calls to the same document — those are two atomic operations, not one
  • It does NOT extend across two different documents — that is what multi-document transactions are for

Command

text
// One update call to one document = atomic. Two calls, or two documents = not, without a transaction.

One write call to one document is always atomic, however many fields it touches — but two separate calls are two atomic operations, not one, even against the same document.

2.Multi-document transactions

A multi-document transaction lets several writes — across one or more documents and collections — succeed or fail together, the same all-or-nothing guarantee a single-document write already has, extended across all of them. Nothing else ever sees a partial result.

Key features

  • Available on replica sets (since MongoDB 4.0) and sharded clusters (since 4.2)
  • A transaction runs within a client session — startSession(), then startTransaction()
  • All reads and writes inside see a consistent snapshot and either all commit or all roll back
  • Transactions have real cost: they hold resources longer and can increase contention — reach for them when the alternative genuinely needs cross-document atomicity, not as a default
  • Many cross-document problems are avoidable by embedding the related data into one document instead, which sidesteps the need for a transaction entirely
A transaction moving funds between two accounts
Client
MongoDB
  1. 1startTransaction()
  2. 2updateOne(accountA, $inc: -50)
  3. 3updateOne(accountB, $inc: +50)
  4. 4commitTransaction()
  5. 5both writes visible together, or neither is
  1. Client → MongoDB: startTransaction()
  2. Client → MongoDB: updateOne(accountA, $inc: -50)
  3. Client → MongoDB: updateOne(accountB, $inc: +50)
  4. Client → MongoDB: commitTransaction()
  5. MongoDB → Client: both writes visible together, or neither is

Command

text
session.startTransaction() → writes using { session } → session.commitTransaction() (or abortTransaction())

A transaction extends single-document atomicity across several documents — everything inside commits together or none of it does. Use it when the alternative genuinely needs cross-document atomicity, not as a default reach.

3.Session usage and transaction lifecycle

Every transaction runs inside a client session — a session is created first, then a transaction is started within it, and every operation meant to be part of that transaction has to pass the same session object. The transaction moves through a defined lifecycle: started, then either committed or aborted.

Key features

  • client.startSession() creates the session; session.startTransaction() begins a transaction within it
  • Every read/write meant to be part of the transaction must pass { session } explicitly
  • A session can be reused for a new transaction after the previous one commits or aborts, but not run two transactions concurrently
  • session.endSession() should always run, typically in a finally block, to release server-side resources
  • A transaction has a maximum lifetime (60 seconds by default) after which it is aborted automatically

Command

text
const session = client.startSession(); session.startTransaction(); /* ...{ session } writes... */ session.commitTransaction(); session.endSession();

A session is the container a transaction runs in — every operation inside the transaction must pass { session } explicitly, and endSession() belongs in a finally block so cleanup always happens.

4.startTransaction, commitTransaction, withTransaction

startTransaction/commitTransaction/abortTransaction are the low-level API — you call each explicitly and handle retries yourself. withTransaction is a driver-provided helper that wraps the same lifecycle and automatically retries the whole block on the transient errors transactions are prone to.

Key features

  • startTransaction() begins the transaction on an existing session
  • commitTransaction() attempts to make every write inside durable and visible together
  • abortTransaction() rolls back every write made inside the transaction
  • withTransaction(fn) runs fn, commits automatically on success, and retries the whole callback on a TransientTransactionError or an UnknownTransactionCommitResult
  • Most official drivers recommend withTransaction (or its language-specific equivalent) as the default over hand-rolling the three calls

Low-level calls vs. withTransaction

Low-level calls vs. withTransaction
ApproachRetry handlingWhen to use
startTransaction/commit/abortmanual — you write the retry loopfine-grained control over retry behavior
withTransaction(fn)automatic, for the documented transient error categoriesthe default choice for most application code

Command

text
session.withTransaction(async () => { /* { session } writes */ })

startTransaction/commitTransaction/abortTransaction are the low-level calls; withTransaction wraps them with the transient-error retry logic MongoDB expects every transaction-using application to have — prefer it as the default.

5.Transaction boundaries and cost

Everything a transaction touches stays held — locks, snapshot resources, oplog space — for as long as the transaction is open. A transaction that stays open for seconds while waiting on an external API call or slow application logic holds those resources the whole time, which is expensive at any real concurrency.

Key features

  • A transaction has a default maximum lifetime (60 seconds) after which it is aborted automatically
  • Held resources include locks on touched documents and WiredTiger snapshot/cache pressure, both of which scale with duration, not just data volume
  • A long-running transaction increases contention with other operations trying to touch the same documents
  • The right boundary contains only the writes that genuinely need to be atomic — never a network call, external API, or slow computation
  • A transaction touching many documents or a very large amount of data is also expensive even if fast, and may be better modeled as smaller batches

Command

text
// Do all slow/non-database work BEFORE startTransaction() or AFTER commitTransaction() — never inside the boundary

Draw the transaction boundary as tight as possible — only the writes that must be atomic go inside; network calls, external APIs, and slow logic go before or after, never inside.

6.Read/write concern in transactions

A transaction has its own read concern and write concern, set once for the whole transaction rather than per operation. MongoDB's drivers default a transaction to readConcern "snapshot" and writeConcern "majority", the strongest common combination, precisely because a transaction is usually protecting something that matters.

Key features

  • readConcern "snapshot" gives every read inside the transaction a consistent point-in-time view, unaffected by concurrent writes committing elsewhere
  • writeConcern is normally set at commitTransaction() time, and "majority" is the default and recommended choice — a committed transaction should be durable
  • Individual operations inside a transaction cannot override the transaction-level read/write concern
  • Weakening writeConcern to reduce latency on a transaction defeats much of the reason to use one — the commit needs to actually be durable
  • A transaction inherits sensible defaults from the driver, so most application code does not need to set these explicitly

Command

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

Read/write concern apply to the whole transaction, not per operation — snapshot + majority are the sensible defaults, and weakening them for latency usually defeats the point of using a transaction at all.

7.Transaction retries and transient errors

Some transaction failures are expected and meant to be retried, not treated as a hard error — a write conflict with another transaction, or a brief replica set election during commit, are both labeled as transient and are safe to retry from the start of the transaction.

Key features

  • TransientTransactionError: the whole transaction can be safely retried from the beginning
  • UnknownTransactionCommitResult: the commit's outcome is unclear (e.g. a network blip during commit) — retrying just the commit, not the whole transaction, is safe
  • These are reported as error labels on the exception, not distinct error codes, so check hasErrorLabel(...) rather than a specific message string
  • A write conflict between two transactions touching the same document is a common, expected source of TransientTransactionError under concurrency
  • withTransaction implements the correct retry behavior for both label types automatically — most application code should prefer it over hand-rolled retry logic

Command

text
err.hasErrorLabel("TransientTransactionError") → retry the whole transaction from the start

TransientTransactionError means retry the whole transaction from the start; UnknownTransactionCommitResult means retry just the commit — check hasErrorLabel(), and prefer withTransaction, which implements this correctly by default.

8.When a transaction is genuinely the right tool

Reach for a transaction only after checking whether the data could instead be modeled as one document — a genuine cross-document invariant (two independent entities that must change together, and cannot reasonably be embedded together) is the real justification, not convenience.

Key features

  • Genuine case: two independently-existing entities (two bank accounts, an order and a separate inventory record) that must change together for one specific operation
  • Not a genuine case: data that was split into multiple documents mainly out of habit, where embedding would model the same invariant for free via single-document atomicity
  • A transaction is justified when the entities have separate identities, separate lifecycles, and are usually read independently
  • The cost of a transaction (locking, resource holding, retry handling) should be weighed against the cost of a schema redesign that avoids needing one
  • Even a genuine multi-document invariant should keep the transaction boundary as small as possible — only the writes the invariant actually requires
Redesign vs. transaction

Could be one document

  • an order and its own line items
  • a user profile and its own settings
  • embed instead — atomicity is free

Genuinely needs a transaction

  • two separate bank accounts
  • an order and an independent inventory collection
  • separate identities and lifecycles — a transaction is the right tool
  • Could be one document
    • an order and its own line items
    • a user profile and its own settings
    • embed instead — atomicity is free
  • Genuinely needs a transaction
    • two separate bank accounts
    • an order and an independent inventory collection
    • separate identities and lifecycles — a transaction is the right tool

Command

text
// Ask first: could embedding model this invariant instead? Only reach for a transaction if the answer is genuinely no.

Ask whether embedding could model the invariant in one document first — reach for a transaction only when the entities genuinely have separate identities and lifecycles that a schema redesign cannot merge.

Remember

  • One write call to one document is always atomic — combine related field changes into one call to get this for free.
  • A transaction extends that same guarantee across documents — real cost, not a default reach.
  • Every operation inside a transaction must pass { session } explicitly, or it runs outside the boundary.
  • Keep the transaction boundary as tight as possible — no network calls or slow logic inside it.

Pro tip

  • Prefer withTransaction over hand-rolled startTransaction/commit/abort — it retries transient errors correctly by default.
  • Ask "could this be one document?" before reaching for a transaction — embedding often removes the need entirely.

Next up

  • Atomicity and Concurrency
  • Read Concern and Write Concern
  • Sessions

Sources

  • Transactions — mongodb.com/docs/manual/core/transactions/
  • Transactions in applications — mongodb.com/docs/manual/core/transactions-in-applications/
  • Checked 2026-09-10

Spotted a mistake on this page? Tell us.