MongoDB Cheatsheet
Transactions
Use this MongoDB reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Requirements
Multi-document transactions need a replica set (4.0+) or a sharded cluster
(4.2+). A standalone mongod cannot run one at all, and there is no flag that changes
that.
MongoServerError: Transaction numbers are only allowed on a replica set
member or mongos| Deployment | Transactions |
|---|---|
Standalone mongod | No. Not configurable |
| Single-node replica set | Yes. This is how you develop locally |
| Multi-node replica set | Yes |
| Sharded cluster | Yes, 4.2+, with more overhead across shards |
| Atlas (any tier) | Yes, every cluster is a replica set |
Converting a local standalone into a one-node replica set is a two-minute job:
mongod --replSet rs0 --dbpath /data/db --bind_ip 127.0.0.1
rs.initiate() // one member, defaults are fine for dev rs.status().myState // 1 means PRIMARY
withTransaction, the pattern to use
session.withTransaction(callback) is the correct API. It starts the transaction,
commits it, and retries on transient failure. Hand-rolled commit logic almost never
handles the retry cases, which is the whole reason this method exists.
const session = db.getMongo().startSession({ readPreference: { mode: "primary" } }); try { session.withTransaction(async () => { const accounts = session.getDatabase("bank").getCollection("balances"); const ledger = session.getDatabase("bank").getCollection("ledger"); const from = accounts.findOne({ _id: "pat", balance: { $gte: 100 } }); if (from === null) throw new Error("insufficient funds"); accounts.updateOne({ _id: "pat" }, { $inc: { balance: -100 } }); accounts.updateOne({ _id: "sasha" }, { $inc: { balance: 100 } }); ledger.insertOne({ from: "pat", to: "sasha", amount: 100, at: new Date() }); }, { readConcern: { level: "snapshot" }, writeConcern: { w: "majority" }, maxCommitTimeMS: 5000 }); } finally { session.endSession(); }
Two things make or break this:
- Every operation must run on the session. In mongosh that means going through
session.getDatabase(...), not the global db. An operation on plain db inside the
callback silently executes outside the transaction and is not rolled back.
- The callback must be idempotent and side-effect free. It can run more than once.
Do not send an email, charge a card, or mutate a JS variable the caller reads.
Throwing from the callback aborts the transaction and the error propagates. That is how you roll back deliberately.
In a driver the same pattern applies, and the session is an explicit argument:
// Node driver const session = client.startSession(); try { await session.withTransaction(async () => { await accounts.updateOne({ _id: "pat" }, { $inc: { balance: -100 } }, { session }); await ledger.insertOne({ amount: 100 }, { session }); }, { readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } }); } finally { await session.endSession(); }
Forgetting { session } on one call is the single most common transaction bug in
driver code, and it fails silently.
Manual control
The explicit form exists and you should reach for it only when you genuinely need to interleave logic between the start and the commit.
const session = db.getMongo().startSession(); session.startTransaction({ readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } }); try { const coll = session.getDatabase("bank").balances; coll.updateOne({ _id: "pat" }, { $inc: { balance: -100 } }); coll.updateOne({ _id: "sasha" }, { $inc: { balance: 100 } }); session.commitTransaction(); } catch (e) { session.abortTransaction(); throw e; } finally { session.endSession(); }
This version has no retry. A write conflict from a concurrent update, or a primary
step-down mid-commit, surfaces as an error your caller has to handle. If you write this,
you are re-implementing withTransaction and you have to implement both retry loops
described below.
Read and write concerns
| Setting | Value | Meaning |
|---|---|---|
readConcern | "snapshot" | Every read in the transaction sees one consistent point in time. The right default |
readConcern | "local" | Default. Latest local data, no snapshot guarantee across reads |
readConcern | "majority" | Data acknowledged by a majority |
writeConcern | { w: "majority" } | Commit acknowledged by a majority. Required for durability across a failover |
writeConcern | { w: 1 } | Primary only. A failover can lose the committed transaction |
readPreference | primary | The only supported value. Transactions never read from a secondary |
maxCommitTimeMS | ms | Cap the commit itself |
Concerns are set on the transaction, not on the individual operations, and an
operation-level writeConcern inside a transaction is an error. Snapshot reads plus
majority writes is the combination that actually gives you the isolation people assume
they already have.
Error labels
MongoDB tags retryable failures with labels rather than making you match error codes.
withTransaction reads exactly these two.
| Label | Means | Correct response |
|---|---|---|
TransientTransactionError | The whole transaction failed cleanly and nothing was applied. Covers WriteConflict, LockTimeout, NoSuchTransaction, and a step-down before commit | Retry the entire transaction from the start |
UnknownTransactionCommitResult | The commit was sent and the outcome is unknown. It may already have succeeded | Retry the commit only, which is safe because commit is idempotent |
// What withTransaction does for you while (true) { try { /* run body, commit */ break } catch (e) { if (e.hasErrorLabel("TransientTransactionError")) continue; // full retry if (e.hasErrorLabel("UnknownTransactionCommitResult")) continue; // commit retry throw e; } }
WriteConflict is normal under concurrency, not a bug. MongoDB uses optimistic
concurrency, so two transactions touching the same document mean one of them aborts and
retries. Design for it by keeping transactions short.
Limits and costs
| Limit | Value |
|---|---|
transactionLifetimeLimitSeconds | 60 seconds by default. A periodic task aborts anything older |
| Individual oplog entry | 16MB. A large transaction is split across entries, but no single entry may exceed it |
| Idle transaction | Holds a snapshot, so it pins WiredTiger cache and blocks oplog truncation |
maxTimeMS | Applies per operation, not to the transaction as a whole |
| Capped collections | Cannot be written to inside a transaction |
count / estimatedDocumentCount | Not supported. countDocuments is |
$out / $merge | Not supported inside a transaction |
| Collection and index creation | Allowed since 4.4, but not implicitly across shards |
| Sharded transactions | Extra coordination per shard. Cost grows with the number of shards touched |
A long-running transaction is the failure mode nobody plans for. It pins a snapshot, grows cache pressure, prevents oplog truncation, and then gets killed at 60 seconds with everything rolled back. Batch large work into many small transactions instead of raising the limit.
When you do not need one
MongoDB's atomicity guarantee is per document, and that is a stronger tool than it sounds. A single-document update is always atomic, however deeply nested the fields and however many operators you apply.
// Atomic without a transaction, because it is one document db.orders.updateOne({ _id: id, status: "pending" }, { $set: { status: "paid", paid_at: new Date() }, $inc: { version: 1 }, $push: { events: { kind: "payment", at: new Date() } } })
| Instead of a transaction | Do this |
|---|---|
| Order plus its line items | Embed the items. One document, one atomic write |
| Read then conditionally write | Put the condition in the filter, or use findOneAndUpdate |
| Optimistic locking | Match on version in the filter and $inc it. Zero matches means somebody else won |
| A counter shared across documents | $inc on one counter document |
| A long multi-step workflow | Persist a state field and drive it forward idempotently |
Transactions are the right answer for a genuine cross-document invariant, such as double-entry bookkeeping or moving an item between two collections. They are the wrong answer for a schema that was drawn as relational tables and then stored as MongoDB collections. If you find yourself needing transactions on every write path, the document model is what needs changing.