ACID Transactions in MongoDB
MongoDB guarantees that a write to a single document is always atomic, but many real-world operations — like moving money between two accounts, or placing an order that must also decrement inventory — touch more than one document, sometimes in more than one collection. For those cases MongoDB provides full ACID multi-document transactions, so a group of operations either all succeed together or all fail together, with no partial results ever visible to other readers. This lesson covers how transactions work internally, the session-based syntax in mongosh, worked examples including a real rollback, and the mistakes that trip people up.
Overview: What ACID Transactions Mean in MongoDB
ACID stands for Atomicity, Consistency, Isolation, and Durability — the classic guarantees relational databases have offered for decades. MongoDB has always given you atomicity and isolation on a single document, even one with deeply nested arrays and subdocuments: an updateOne() that changes five fields inside one document either applies all five or none, and no other client can see it half-applied. Since MongoDB 4.0, that same guarantee extends across multiple documents in a single replica set, and since MongoDB 4.2, across multiple documents spanning multiple shards in a sharded cluster.
A transaction in MongoDB is built around a client session — a logical, ordered sequence of operations identified by a session ID (lsid) and, once a transaction starts, a transaction number (txnNumber). Every operation you want included in the transaction must explicitly carry that session; anything you run without it happens completely outside the transaction, immediately visible to everyone, and is not rolled back if the transaction later aborts.
Transactions require a replica set (a standalone mongod has no oplog, and the oplog is what transactions build on for their snapshot and rollback machinery). If you are running MongoDB locally for practice, it needs to be initialized as at least a single-node replica set before transactions will work — see this course’s Replica Sets lesson for rs.initiate(). In production you will almost always already be on a replica set or sharded cluster, so this is rarely a real obstacle.
Because transactions have real cost — they hold locks, consume extra memory to buffer changes, and add network round-trips — MongoDB’s own guidance, echoed throughout this lesson, is: reach for a transaction only when you genuinely need multiple documents (or collections) to change atomically together. If a single updateOne() or a well-designed embedded document can express the same requirement, that is almost always the better, faster, simpler choice.
Syntax
The general shape of a manual transaction looks like this:
// General pattern for a multi-document transaction in mongosh
const session = db.getMongo().startSession();
session.startTransaction({
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" }
});
try {
const accounts = session.getDatabase("bank").accounts;
accounts.updateOne(
{ _id: "A123" },
{ $inc: { balance: -100 } },
{ session }
);
accounts.updateOne(
{ _id: "B456" },
{ $inc: { balance: 100 } },
{ session }
);
session.commitTransaction();
} catch (error) {
session.abortTransaction();
throw error;
} finally {
session.endSession();
}
| Method / option | Purpose |
|---|---|
db.getMongo().startSession() |
Opens a client session; returns a session object you pass to every operation you want inside the transaction. |
session.startTransaction(options) |
Begins the transaction on that session. Must be paired with a later commitTransaction() or abortTransaction(). |
session.commitTransaction() |
Makes every buffered write in the transaction durable and visible to other clients atomically, all at once. |
session.abortTransaction() |
Discards every write made so far in the transaction; nothing is applied. |
session.withTransaction(fn, options) |
Convenience wrapper: runs fn, commits on success, aborts on a thrown error, and automatically retries the whole transaction on certain transient errors (a primary election, a write conflict). This is the recommended way to write transactions. |
session.endSession() |
Releases the client-side session resources. Always call this, even after an abort. |
readConcern: { level: "snapshot" } |
All reads inside the transaction see a single consistent point-in-time snapshot of the data, unaffected by concurrent writes from other clients. |
writeConcern: { w: "majority" } |
The commit is only acknowledged once a majority of replica set members have applied it, so a committed transaction survives a primary failover. |
readPreference |
Which member to read from during the transaction. Must be "primary" for transactions that touch a sharded cluster. |
Examples
Example 1: A guarded bank transfer
This transfers 100 from one account to another, but first checks the sender has enough balance and aborts cleanly if not.
const session = db.getMongo().startSession();
session.startTransaction({
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" }
});
try {
const accounts = session.getDatabase("bank").accounts;
const sender = accounts.findOne({ _id: "A123" }, { session });
if (sender.balance < 100) {
throw new Error("Insufficient funds");
}
accounts.updateOne(
{ _id: "A123" },
{ $inc: { balance: -100 } },
{ session }
);
accounts.updateOne(
{ _id: "B456" },
{ $inc: { balance: 100 } },
{ session }
);
session.commitTransaction();
print("Transfer committed");
} catch (error) {
session.abortTransaction();
print("Transfer aborted: " + error.message);
} finally {
session.endSession();
}
Output:
Transfer committed
// db.getSiblingDB("bank").accounts.find()
[
{ _id: 'A123', balance: 400 },
{ _id: 'B456', balance: 600 }
]
Both updateOne() calls carry { session }, so they are buffered as part of the same transaction. Neither balance change is visible to any other client until commitTransaction() succeeds, at which point both appear together.
Example 2: withTransaction() for an order + inventory write
withTransaction() is the recommended pattern because it automatically retries the whole callback if MongoDB reports a transient error such as a mid-transaction primary election — something manual startTransaction/commitTransaction code has to handle by hand.
const session = db.getMongo().startSession();
const txnOptions = {
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" },
readPreference: "primary"
};
try {
session.withTransaction(() => {
const shopDb = session.getDatabase("shop");
const orders = shopDb.orders;
const inventory = shopDb.inventory;
const item = inventory.findOne({ sku: "LAPTOP-15", stock: { $gte: 1 } }, { session });
if (!item) {
throw new Error("Out of stock");
}
orders.insertOne(
{ _id: "ORD-1001", sku: "LAPTOP-15", qty: 1, status: "pending" },
{ session }
);
inventory.updateOne(
{ sku: "LAPTOP-15" },
{ $inc: { stock: -1 } },
{ session }
);
}, txnOptions);
print("Order transaction committed");
} finally {
session.endSession();
}
Output:
Order transaction committed
// db.getSiblingDB("shop").inventory.findOne({ sku: "LAPTOP-15" })
{ _id: ObjectId('66f1a2b3c4d5e6f7a8b9c0d1'), sku: 'LAPTOP-15', stock: 41 }
// db.getSiblingDB("shop").orders.findOne({ _id: "ORD-1001" })
{ _id: 'ORD-1001', sku: 'LAPTOP-15', qty: 1, status: 'pending' }
The order document and the stock decrement land together. If either write had failed, withTransaction() would have aborted both.
Example 3: Watching a real rollback happen
This example inserts an order first, then checks stock — deliberately structured so you can see that even a write that already "happened" inside the transaction gets fully undone when the transaction aborts.
const session = db.getMongo().startSession();
const txnOptions = {
readConcern: { level: "snapshot" },
writeConcern: { w: "majority" }
};
try {
session.withTransaction(() => {
const shopDb = session.getDatabase("shop");
const orders = shopDb.orders;
const inventory = shopDb.inventory;
orders.insertOne(
{ _id: "ORD-1002", sku: "MOUSE-01", qty: 1, status: "pending" },
{ session }
);
const result = inventory.updateOne(
{ sku: "MOUSE-01", stock: { $gte: 1 } },
{ $inc: { stock: -1 } },
{ session }
);
if (result.matchedCount === 0) {
throw new Error("Out of stock \u2014 rolling back order");
}
}, txnOptions);
print("Order transaction committed");
} catch (error) {
print("Order transaction aborted: " + error.message);
} finally {
session.endSession();
}
Output:
Order transaction aborted: Out of stock \u2014 rolling back order
// db.getSiblingDB("shop").orders.findOne({ _id: "ORD-1002" })
null
// db.getSiblingDB("shop").inventory.findOne({ sku: "MOUSE-01" })
{ _id: ObjectId('66f1a2b3c4d5e6f7a8b9c0d2'), sku: 'MOUSE-01', stock: 0 }
Even though insertOne() ran without an error inside the callback, the order never actually exists once the transaction aborts — findOne() outside the transaction returns null. That is the whole point of a transaction: nothing inside it is real until commitTransaction() succeeds.
How Transactions Work Step by Step
1. Session creation. startSession() assigns a logical session ID. Every subsequent transaction on that session gets an incrementing txnNumber, which MongoDB uses to detect retried operations and avoid double-applying them.
2. Snapshot read view. With readConcern: "snapshot", the storage engine (WiredTiger) gives every read in the transaction a consistent point-in-time view using multi-version concurrency control (MVCC) — concurrent writes from other clients simply are not visible, even if they commit while your transaction is still open.
3. Buffered writes. Writes inside the transaction are recorded but not yet visible outside it. Under the hood MongoDB takes the necessary locks on the affected documents as each write happens, to protect against conflicting concurrent writers.
4. Commit. commitTransaction() applies every buffered write atomically and replicates it to the oplog as a single unit. With writeConcern: "majority", the driver waits until a majority of replica set members have the commit before returning success, so a committed transaction survives a subsequent primary failover. In a sharded cluster spanning multiple shards, MongoDB coordinates this with a two-phase commit protocol across the involved shards, which is why cross-shard transactions are noticeably slower than single-shard ones.
5. Abort. abortTransaction() (or a thrown error inside withTransaction()) discards every buffered write and releases all locks. Nothing the transaction did is ever visible to anyone.
6. Timeout. By default a transaction has a 60-second lifetime (transactionLifetimeLimitSeconds) — if it isn't committed or aborted within that window, the server aborts it automatically. This is a safety net against transactions that are held open too long and block other writers.
Common Mistakes
Mistake: wrapping a single-document write in a transaction
A single-document update is already atomic in MongoDB. Wrapping it in a transaction adds session overhead and extra failure modes for zero extra consistency benefit.
Wrong:
const session = db.getMongo().startSession();
session.startTransaction();
const users = session.getDatabase("app").users;
users.updateOne(
{ _id: "u_789" },
{ $set: { lastLogin: new Date() } },
{ session }
);
session.commitTransaction();
session.endSession();
Corrected:
db.users.updateOne(
{ _id: "u_789" },
{ $set: { lastLogin: new Date() } }
);
Mistake: forgetting { session } on one of the operations
Every operation that should be part of the transaction must explicitly carry the session. Omit it on even one call and that write executes and commits immediately on its own, outside the transaction — it will not be rolled back if the transaction later aborts, silently breaking atomicity.
Wrong:
const session = db.getMongo().startSession();
session.startTransaction();
const accounts = session.getDatabase("bank").accounts;
accounts.updateOne(
{ _id: "A123" },
{ $inc: { balance: -100 } },
{ session }
);
// Missing { session } here \u2014 this write is NOT part of the transaction
accounts.updateOne(
{ _id: "B456" },
{ $inc: { balance: 100 } }
);
session.commitTransaction();
session.endSession();
Corrected:
const session = db.getMongo().startSession();
session.startTransaction();
const accounts = session.getDatabase("bank").accounts;
accounts.updateOne(
{ _id: "A123" },
{ $inc: { balance: -100 } },
{ session }
);
accounts.updateOne(
{ _id: "B456" },
{ $inc: { balance: 100 } },
{ session }
);
session.commitTransaction();
session.endSession();
A related, less obvious pitfall: running the exact same transaction code against a standalone mongod instead of a replica set fails immediately, because transactions rely on the oplog:
MongoServerError: Transaction numbers are only allowed on a replica set member or mongos
And even on a replica set, holding a transaction open too long — waiting on an external API call, or looping over a huge number of documents inside it — increases the chance of hitting the default 60-second limit or colliding with other writers, so keep everything inside a transaction fast and index-backed.
Best Practices
- Only use a transaction when multiple documents or collections must change together atomically; single-document writes are already atomic on their own.
- Prefer
session.withTransaction()over manualstartTransaction/commitTransaction/abortTransaction— it retries automatically on expected transient errors like a primary election. - Pass
{ session }to every single operation that should be inside the transaction; double-check this in code review, since it's the easiest way to silently break atomicity. - Keep transactions short: read only what you need, avoid network calls or waiting on user input inside one, and don't loop over unbounded numbers of documents.
- Make sure every field you query or update inside a transaction is index-backed — slow lookups mean locks are held longer, increasing contention with other writers.
- Use
readConcern: "snapshot"andwriteConcern: "majority"(the defaultswithTransaction()applies) for the strongest consistency guarantees. - In a sharded cluster, try to keep a transaction's operations on as few shards as possible — cross-shard transactions use a slower two-phase commit.
- Always release the session with
session.endSession()in afinallyblock, whether the transaction committed or aborted.
Practice Exercises
- Write a transaction that transfers 50 loyalty points from one customer's
loyaltyPointsfield to another's in adb.customerscollection, aborting with a clear error if the sender doesn't have enough points. Verify with a plainfind()(no session) that a failed attempt leaves both documents unchanged. - Rewrite your exercise 1 transaction using
session.withTransaction()with explicitreadConcern: "snapshot"andwriteConcern: "majority"options. - Using a
db.ordersanddb.inventorypair of collections, write a transaction that inserts a new order and decrements the matching product's stock only if enough stock exists, otherwise aborts. Then confirm withfindOne()that a rejected order never appears indb.orders.
Summary
- MongoDB supports multi-document ACID transactions on replica sets since version 4.0 and across sharded clusters since version 4.2.
- Single-document writes are already atomic — reach for a transaction only when several documents or collections must change together.
- Transactions run through a client session:
startTransaction()/commitTransaction()/abortTransaction(), or the safersession.withTransaction()wrapper with built-in retry on transient errors. - Every operation inside a transaction must explicitly receive
{ session }, or it silently runs outside the transaction. - Internally, transactions use MVCC snapshots for consistent reads, buffer writes until commit, and (across shards) coordinate a two-phase commit.
- Keep transactions short and index-backed — they hold locks and are subject to a default 60-second lifetime limit.
