Multi-Document Transactions
Multi-document ACID transactions, a complete example, and when you need them versus single-document atomicity.
Single-document atomicity already exists, for free
Before reaching for a multi-document transaction, it's worth being precise about what MongoDB already guarantees without one: a write to a single document is always atomic, even if that write touches several fields at once. An updateOne that increments a counter, pushes onto an array, and sets a status field all in one call either fully applies or doesn't apply at all — there's no in-between state where only some of those field changes took effect, and no transaction needs to be started to get that guarantee.
// Already atomic, with no transaction needed at all
db.accounts.updateOne(
{ _id: "userA" },
{ $inc: { balance: -100 }, $push: { history: { type: "debit", amount: 100 } } }
);
This is exactly why the schema-design page pushes toward embedding related data into one document where it reasonably fits — a document that holds everything a given operation needs to change already gets atomicity as a side effect of the document model, with no extra machinery required.
When you actually need a multi-document transaction
Sometimes the data genuinely can't (or shouldn't) live in one document — a transfer between two separate accounts documents, for instance, where "debit one, credit the other" must succeed or fail as a single unit, and the two accounts are independent documents by necessity (each needs to be queried, updated, and paginated on its own). This is the case MongoDB's multi-document ACID transactions (available since MongoDB 4.0 for replica sets, and since 4.2 across sharded clusters) exist for.
const session = client.startSession();
try {
session.startTransaction();
db.accounts.updateOne(
{ _id: "userA" },
{ $inc: { balance: -100 } },
{ session }
);
db.accounts.updateOne(
{ _id: "userB" },
{ $inc: { balance: 100 } },
{ session }
);
session.commitTransaction();
} catch (error) {
session.abortTransaction();
throw error;
} finally {
session.endSession();
}
Every operation that should participate in the transaction must explicitly pass { session } — an operation issued without it runs completely outside the transaction, immediately and independently, regardless of what the transaction around it later does.
withTransaction: the safer default
Writing the manual startTransaction/commitTransaction/abortTransaction sequence by hand also means handling transient errors yourself — certain failures (a brief network blip, a write conflict with another concurrent transaction) are expected to be retried, not treated as a hard failure. withTransaction() wraps that retry logic for you:
const session = client.startSession();
try {
await session.withTransaction(async () => {
await db.accounts.updateOne({ _id: "userA" }, { $inc: { balance: -100 } }, { session });
await db.accounts.updateOne({ _id: "userB" }, { $inc: { balance: 100 } }, { session });
});
} finally {
await session.endSession();
}
If the callback throws, withTransaction() aborts automatically; if it hits a retryable transient error, it retries the whole callback automatically rather than surfacing the error to the caller — this is the recommended way to write a MongoDB transaction in practice, rather than the fully manual version above.
Real requirements and costs
Multi-document transactions require a replica set (or a sharded cluster) — they rely on the same replication oplog that backs replica set durability, so they cannot run against a single standalone mongod with no replication configured at all. They also have a real performance cost relative to independent single-document writes: a transaction holds resources and locks for its duration, has a default maximum runtime (60 seconds), and under contention can abort and need retrying — none of which apply to an ordinary single-document write.
Deciding: transaction, or should this have been one document?
| Single-document write | Multi-document transaction | |
|---|---|---|
| Atomicity | Automatic, no extra code | Requires an explicit session and transaction |
| Performance | Normal write cost | Higher — holds resources for the transaction's duration |
| Requires replica set/cluster | No | Yes |
| Right for | Data that fits together in one document | Genuinely independent documents that must change together |
The practical discipline: when reaching for a transaction, ask first whether the two (or more) documents involved should actually have been modeled as one document via embedding — if so, the schema-design decision, not the transaction, is the real fix. A multi-document transaction is best treated as an escape hatch for cases embedding genuinely can't cover (independent documents, queried and updated separately, that occasionally need one specific operation to be atomic across them) — not the default, everyday tool for multi-part writes the way transactions are in a relational database.
Common mistakes
- Reaching for a multi-document transaction as a first resort for "this operation touches two documents," without first asking whether those documents should have been modeled as one — this is the single most common transaction-related overuse in MongoDB.
- Running transaction code against a standalone, non-replica-set MongoDB instance (common in a quick local dev setup) and hitting an error that doesn't reproduce once deployed against a real replica set.
- Forgetting to pass
{ session }on every operation meant to be part of the transaction — an operation missing it commits immediately and independently, regardless of whether the surrounding transaction later commits or aborts. - Writing the manual
startTransaction/commitTransactionpattern without retry handling for transient errors, instead of usingwithTransaction(), which handles the expected retry cases automatically.