Filter concepts by levelShowing all levels.

MongoDB · Section 21

Transactions

Level
advanced
Read
35 min
Concepts
8

Single-document atomicity as the guarantee that comes free with every write, multi-document transactions as the way to extend it across documents, the session-based lifecycle (start, commit or abort), the low-level API versus the withTransaction helper and its automatic retry handling, why a transaction's cost is tied to how long it stays open, the read/write concern it runs with, and how to tell a genuine cross-document invariant from data that should simply have been modeled as one document.

MongoDB overview

What is true here

  1. A single write call to one document is always atomic, however many fields it touches — this is free, no transaction needed.
  2. A multi-document transaction extends that same all-or-nothing guarantee across documents and collections, at real cost.
  3. Every operation inside a transaction must explicitly pass { session }, or it runs outside the transaction boundary.
  4. Transaction cost is proportional to how long it stays open — keep the boundary tight, with no network calls or slow logic inside it, and handle its expected transient errors with withTransaction.
  5. Reach for a transaction only for a genuine cross-document invariant — check first whether embedding the data into one document would remove the need entirely.

What you will be able to do

  • Explain what single-document atomicity already guarantees, and where it stops
  • Write a correct multi-document transaction using a session
  • Choose withTransaction over hand-rolled retry logic, and know why
  • Keep a transaction boundary tight and reason about what it costs to leave it open
  • Decide whether a given cross-document consistency need justifies a transaction or a schema redesign

Atomicity and lifecycle

What is already atomic for free, how a transaction extends that, and the session-based lifecycle it runs through.

Single-document atomicity

standardintermediate

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.

Think of it as

This is the guarantee MongoDB gives you for free, on every write, without asking for it. Transactions exist to extend that same all-or-nothing guarantee across more than one document — understanding what is already atomic is what tells you whether you actually need one.

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

What we're doing: Show one update touching several fields atomically, versus two separate calls that are not atomic with each other.

javascript
// Atomic: one call, multiple fields on one document
db.accounts.updateOne(
  { _id: "acct-1" },
  { $inc: { balance: -50 }, $push: { history: { type: "debit", amount: 50 } } }
)

// NOT atomic with each other: two separate calls, even on the same document
db.accounts.updateOne({ _id: "acct-1" }, { $inc: { balance: -50 } })
db.accounts.updateOne({ _id: "acct-1" }, { $push: { history: { type: "debit", amount: 50 } } })
2
Both the balance change and the history entry land together, in one atomic operation — no reader ever sees one without the other.
8
These are two separate atomic operations back to back — a crash or a concurrent read between them could observe the balance already debited but no history entry yet.

Why this works: Single-document atomicity is defined per write call, not per logical unit of work — combining related changes into one update call is what earns the atomicity, splitting them into two calls loses it even against the same document.

Remember: 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.

See also: multi document transactions · single document writes are atomic

Multi-document transactions

coreadvanced

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.

Think of it as

Single-document atomicity is a wall around one document; a transaction moves that wall to enclose several documents at once. Everything inside commits together or nothing does — the same mental model as a SQL transaction, on a database that mostly avoids needing one by embedding related data together in the first place.

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

What we're doing: Move funds between two accounts atomically — the classic case a single document cannot model.

fund-transfer.jsjavascript
const session = client.startSession();
try {
  session.startTransaction();
  await accounts.updateOne({ _id: "acct-A" }, { $inc: { balance: -50 } }, { session });
  await accounts.updateOne({ _id: "acct-B" }, { $inc: { balance: 50 } }, { session });
  await session.commitTransaction();
} catch (err) {
  await session.abortTransaction();
  throw err;
} finally {
  await session.endSession();
}
4
Every operation inside the transaction must pass the same session, or it runs outside the transaction entirely — a common mistake.
8
If anything fails before commitTransaction(), abortTransaction() rolls back every write made inside the transaction, including the first updateOne that already ran.

Why this works: Two accounts are two separate documents, so single-document atomicity alone cannot guarantee both balances change together — a transaction is what makes "debit A and credit B" a single atomic unit instead of two independent writes that could be observed half-done.

Forgetting to pass { session } to one of the writes inside the transaction

Wrong

javascript
await accounts.updateOne({ _id: "acct-B" }, { $inc: { balance: 50 } });  // missing { session }

Better

javascript
await accounts.updateOne({ _id: "acct-B" }, { $inc: { balance: 50 } }, { session });

What you see: The second write commits immediately and independently, even if the transaction later aborts — the funds appear to be created from nothing, or the transfer is only half-reversed on rollback.

Why: A write without the session option runs as its own separate operation, entirely outside the transaction's all-or-nothing boundary — every operation meant to be part of the transaction has to explicitly carry the same session.

A transaction moving funds between two accounts
Client
MongoDB
  1. 1. startTransaction()
  2. 2. updateOne(accountA, $inc: -50)
  3. 3. updateOne(accountB, $inc: +50)
  4. 4. commitTransaction()
  5. 5. both 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

Remember: 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.

See also: single document atomicity · cross document invariants · transaction lifecycle and sessions

Session usage and transaction lifecycle

standardadvanced

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.

Think of it as

A session is the container; a transaction is one thing that can happen inside it. A session can run several transactions over its lifetime, one after another, but never two active at once — the lifecycle is strictly start → (writes) → commit or abort, then the session is free for the next transaction or ends.

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

What we're doing: Show the full lifecycle from session creation through cleanup, including the finally block that always runs.

javascript
const session = client.startSession();
try {
  session.startTransaction();
  await ordersCol.insertOne({ ... }, { session });
  await inventoryCol.updateOne({ sku }, { $inc: { stock: -1 } }, { session });
  await session.commitTransaction();
} catch (err) {
  await session.abortTransaction();
  throw err;
} finally {
  await session.endSession();
}
1
The session is the container that both operations below will be tied to.
9
endSession() always runs, whether the transaction committed or was aborted, to release the session's server-side resources.

Why this works: Every stage of the lifecycle exists for a reason: the try/catch ensures a failure triggers an explicit rollback rather than leaving the transaction hanging, and the finally block guarantees cleanup happens regardless of which path was taken.

Remember: 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.

See also: multi document transactions · transaction api methods · logical sessions

startTransaction, commitTransaction, withTransaction

standardadvanced

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.

Think of it as

Writing the low-level calls by hand means you own retry logic MongoDB explicitly expects you to have, because certain errors (like a transient transaction error during commit) are meant to be retried, not treated as a hard failure. withTransaction exists because that retry logic is the same in almost every case, so most drivers give you the correct version for free.

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

What we're doing: Contrast manual retry handling against withTransaction doing the same job.

javascript
// Manual: you own the retry loop
while (true) {
  try {
    session.startTransaction();
    await doWrites(session);
    await session.commitTransaction();
    break;
  } catch (err) {
    await session.abortTransaction();
    if (err.hasErrorLabel("TransientTransactionError")) continue;
    throw err;
  }
}

// withTransaction: the same retry behavior, handled for you
await session.withTransaction(() => doWrites(session));
2
This manual loop exists specifically to retry on a TransientTransactionError, which the driver documents as something a correct application is expected to retry.
16
withTransaction implements the same retry contract internally, so most application code does not need to hand-write it.

Why this works: Transient errors during a transaction are a normal, documented part of using them — not an edge case — so the retry loop above is not defensive extra code, it is the correctly-behaving version, and withTransaction is worth using specifically because it gets that loop right by default.

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

Together

javascript
await session.withTransaction(async () => {
  await accounts.updateOne({ _id: "A" }, { $inc: { balance: -50 } }, { session });
  await accounts.updateOne({ _id: "B" }, { $inc: { balance: 50 } }, { session });
});

Remember: 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.

See also: transaction lifecycle and sessions · transaction retries and transient errors

Advertisement

Cost, durability, and judgment

What a transaction costs to hold open, the guarantees it commits with, handling its expected errors, and deciding when it is genuinely the right tool.

Transaction boundaries and cost

standardadvanced

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.

Think of it as

A transaction's cost is proportional to how long it stays open, not just how much data it touches — the boundary should be drawn as tightly as possible around the database work itself, with anything slow (network calls, business logic, user interaction) done before it starts or after it commits.

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

What we're doing: Contrast a transaction boundary that includes a slow external call against one that keeps the boundary tight.

javascript
// Expensive: an external payment API call inside the transaction boundary
await session.withTransaction(async () => {
  const result = await paymentGateway.charge(amount); // network call, could take seconds
  await orders.updateOne({ _id }, { $set: { status: "paid" } }, { session });
});

// Better: call the external service first, only the database writes are inside the boundary
const result = await paymentGateway.charge(amount);
await session.withTransaction(async () => {
  await orders.updateOne({ _id }, { $set: { status: "paid", paymentRef: result.id } }, { session });
});
2
The transaction holds its locks and snapshot resources for however long the payment call takes — seconds of contention for one order.
8
Calling the external service first means the transaction itself only ever holds resources for the brief database write, regardless of how slow the payment gateway is.

Why this works: Locks and snapshot resources are held for the transaction's entire open duration — anything slow inside that boundary multiplies the contention every other operation touching the same data experiences, which is why the boundary should contain only the database work that actually needs atomicity.

Remember: 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.

See also: multi document transactions · transaction retries and transient errors

Read/write concern in transactions

standardadvanced

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.

Think of it as

Outside a transaction, you can tune read/write concern per operation to trade consistency for speed where it is safe to. Inside a transaction, the concern applies to the whole unit — you are choosing durability and consistency guarantees for the entire all-or-nothing operation, not for one write in isolation.

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

What we're doing: Set explicit read/write concern on a transaction protecting a financial transfer, where the defaults are exactly what is wanted.

javascript
session.startTransaction({
  readConcern: { level: "snapshot" },
  writeConcern: { w: "majority" }
});
await accounts.updateOne({ _id: "A" }, { $inc: { balance: -50 } }, { session });
await accounts.updateOne({ _id: "B" }, { $inc: { balance: 50 } }, { session });
await session.commitTransaction();
2
snapshot read concern means both account reads inside the transaction see a consistent point in time, unaffected by any other transfer committing concurrently.
3
majority write concern on commit means the transfer is not considered done until it is durable across a majority of replica set members — the same durability guarantee you would want for a single critical write.

Why this works: These are the transaction-level defaults for a reason: a fund transfer is exactly the kind of operation where a weaker read or write concern would trade away the consistency and durability the transaction exists to provide in the first place.

Remember: 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.

See also: multi document transactions · write concern acknowledgement and durability

Transaction retries and transient errors

standardadvanced

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.

Think of it as

A transaction operates on a snapshot and can conflict with other concurrent writes touching the same documents — this is a normal, expected outcome of running multiple transactions concurrently, not a bug. The driver labels these failures so your code can tell "try again" apart from "this is actually broken".

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

What we're doing: Handle a transient transaction error correctly by retrying from the top, not the middle.

javascript
while (true) {
  const session = client.startSession();
  try {
    session.startTransaction();
    await doTransfer(session);
    await session.commitTransaction();
    break;
  } catch (err) {
    await session.abortTransaction();
    if (err.hasErrorLabel("TransientTransactionError")) {
      continue; // retry the whole transaction from the top
    }
    throw err;
  } finally {
    await session.endSession();
  }
}
10
Checking hasErrorLabel is the documented way to detect a retryable failure — matching on an error message would be fragile and unsupported.
11
The retry restarts the entire transaction body, not just the failed operation — a transaction that partially applied has already been aborted, so there is no partial state to resume from.

Why this works: Write conflicts between concurrent transactions are expected under real load, not a sign of a broken system — the whole point of the TransientTransactionError label is to distinguish "try again, this is normal" from an error that means something is actually wrong.

Remember: 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.

See also: transaction api methods · transaction boundaries and cost

When a transaction is genuinely the right tool

coreadvanced

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.

Think of it as

Ask the schema-design question first: could this be one document instead? If the two things genuinely have separate identities, separate lifecycles, and are read independently most of the time — a transfer between two account documents, an order plus a separate inventory decrement — a transaction is the right tool. If they are really one aggregate that was just split up out of habit, redesigning the schema is usually the better fix.

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

What we're doing: Contrast a case that should be redesigned into one document against a case that genuinely needs a transaction.

redesign-vs-transaction.jsjavascript
// Should be redesigned: order and its line items split into two collections out of habit
// -> embed items directly in the order document instead; single-document atomicity now covers it
{ _id: "order-1", items: [{ sku: "a", qty: 2 }, { sku: "b", qty: 1 }], total: 42.00 }

// Genuinely needs a transaction: two independent accounts, each with its own lifecycle
await session.withTransaction(async () => {
  await accounts.updateOne({ _id: "A" }, { $inc: { balance: -50 } }, { session });
  await accounts.updateOne({ _id: "B" }, { $inc: { balance: 50 } }, { session });
});
3
Once items live inside the order document, single-document atomicity already guarantees the order and its items change together — no transaction needed for this relationship at all.
6
Account A and account B are genuinely separate entities with their own independent lifecycle — they cannot reasonably be merged into one document, so the transaction is the actual right tool here.

Why this works: The first case's "need" for cross-document atomicity was really a schema smell — splitting inherently co-accessed data into two collections and then reaching for a transaction to paper over it costs more than just embedding it would have. The second case has no such escape: two accounts are not the same entity.

Reaching for a transaction to hold together data that never needed to be split into multiple documents

Wrong

text
// order + separate orderItems collection, joined and kept "consistent" via a transaction on every write

Better

text
// Embed items directly in the order document — the relationship is now atomic by construction, no transaction required

What you see: Every order write pays transaction overhead and contention risk for a relationship that single-document atomicity would have covered for free if modeled as one document.

Why: A transaction is a workaround for a schema that split co-accessed, tightly-coupled data across documents — when the split was not necessary in the first place, removing it is cheaper than compensating for it with a transaction on every write.

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

Remember: 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.

See also: multi document transactions · single document atomicity · embed and reference signals

Advertisement