intermediate~4h

MongoDB Transactions

"MongoDB doesn't support transactions" was true once, and is a genuinely dangerous half-truth to repeat in an interview today. This chapter is exactly what's true now, and exactly what still isn't.

Even before MongoDB had multi-document transactions at all, every single write to one document — however deeply nested, however many fields it touches — has always been fully atomic. This is a direct consequence of MongoDB's document model: if your entire "order" (items, shipping address, totals) fits inside one document, you already have atomicity for free, with zero special syntax.

db.orders.updateOne( { _id: "order123" }, { $set: { status: "SHIPPED" }, $push: { events: { type: "SHIPPED", at: new Date() } }, $inc: { version: 1 } } ); // all three effects (set, push, inc) apply atomically as one document write — // no other reader ever sees a partially-applied version of this update

💻 Code example

db.orders.updateOne( { _id: "order123" }, { $set: { status: "SHIPPED" }, $push: { events: { type: "SHIPPED", at: new Date() } }, $inc: { version: 1 } } ); // all three effects (set, push, inc) apply atomically as one document write — // no other reader ever sees a partially-applied version of this update

◆ The problem

The classic bank-transfer story (Chapter 01) requires updating two separate documents — the sender's account and the receiver's account — atomically together. Single-document atomicity alone can't help here; you need a real, ACID, multi-document transaction, which MongoDB has fully supported since version 4.0 (replica sets) and 4.2 (sharded clusters).

◆ Under the hood — why a single standalone MongoDB instance can't do this

Multi-document transactions in MongoDB are built on the same underlying replication and journaling infrastructure that makes replica sets durable and consistent — the transaction's operation log entries need to be replicated and acknowledged the same way any write is, and MongoDB's implementation of transaction snapshots relies on the oplog (operations log) that only exists in a replica set configuration. A standalone mongod with no replica set (even a single-node one) simply cannot run startTransaction() — this is a very common "gotcha" in local development, where a developer's single-node Mongo setup mysteriously can't do what the production replica-set cluster can.

▲ Common mistake

For local development, you need at minimum a single-node replica set (rs.initiate() on a standalone instance converts it), not a genuinely standalone MongoDB instance, or every multi-document transaction call will fail immediately with an explicit error about replica set requirements.

try (ClientSession session = mongoClient.startSession()) { try { session.startTransaction(); MongoCollection<Document> accounts = database.getCollection("accounts"); accounts.updateOne(session, eq("_id", "A"), inc("balance", -10000)); accounts.updateOne(session, eq("_id", "B"), inc("balance", 10000)); session.commitTransaction(); } catch (MongoException e) { session.abortTransaction(); // the Mongo equivalent of SQL's ROLLBACK throw e; } }

💻 Code example

try (ClientSession session = mongoClient.startSession()) { try { session.startTransaction(); MongoCollection<Document> accounts = database.getCollection("accounts"); accounts.updateOne(session, eq("_id", "A"), inc("balance", -10000)); accounts.updateOne(session, eq("_id", "B"), inc("balance", 10000)); session.commitTransaction(); } catch (MongoException e) { session.abortTransaction(); // the Mongo equivalent of SQL's ROLLBACK throw e; } }
@Configuration public class MongoConfig { @Bean public MongoTransactionManager transactionManager(MongoDatabaseFactory dbFactory) { return new MongoTransactionManager(dbFactory); // a PlatformTransactionManager, from Chapter 10 } } @Service public class WalletService { @Transactional // the SAME annotation from Chapter 08 — now backed by Mongo instead of JDBC public void transfer(String fromId, String toId, int amount) { mongoTemplate.updateFirst(query(where("_id").is(fromId)), new Update().inc("balance", -amount), Account.class); mongoTemplate.updateFirst(query(where("_id").is(toId)), new Update().inc("balance", amount), Account.class); } }

◆ Under the hood

This is a genuinely satisfying payoff of Chapter 10's PlatformTransactionManager abstraction — the exact same @Transactional annotation, AOP proxy, and rollback rules (Chapter 08 §2) apply identically whether the underlying PlatformTransactionManager is DataSourceTransactionManager (plain JDBC), JpaTransactionManager (Hibernate), or MongoTransactionManager — Spring's transaction abstraction was deliberately designed to be resource-agnostic at the annotation level.

💻 Code example

@Configuration public class MongoConfig { @Bean public MongoTransactionManager transactionManager(MongoDatabaseFactory dbFactory) { return new MongoTransactionManager(dbFactory); // a PlatformTransactionManager, from Chapter 10 } } @Service public class WalletService { @Transactional // the SAME annotation from Chapter 08 — now backed by Mongo instead of JDBC public void transfer(String fromId, String toId, int amount) { mongoTemplate.updateFirst(query(where("_id").is(fromId)), new Update().inc("balance", -amount), Account.class); mongoTemplate.updateFirst(query(where("_id").is(toId)), new Update().inc("balance", amount), Account.class); } }
SettingControls
Write concernHow many replica set members must acknowledge a write before it's considered successful — e.g. { w: "majority" } requires acknowledgment from a majority of voting members.
Read concernWhat guarantee a read has about the data it returns — e.g. "snapshot" read concern (used within transactions) guarantees a consistent point-in-time view, analogous to SQL's Serializable isolation (Chapter 04).

▲ Common mistake

A transaction with no explicit settings does NOT default to the strongest option. If you don't set a read concern on the transaction, session, or client, it falls back to "local" — the same as any ordinary read, with no snapshot guarantee. "snapshot" read concern has to be requested explicitly. Write concern is friendlier: since MongoDB 5.0 the cluster-wide default is { w: "majority" }, so a transaction's commit is majority-acknowledged even with no explicit setting. To get the SQL-Serializable-equivalent guarantee, request it on purpose: session.startTransaction({ readConcern: { level: "snapshot" }, writeConcern: { w: "majority" } }).

▲ Common mistake — and MongoDB's own guidance

Reaching for multi-document transactions as a default habit, the way a relational developer reaches for BEGIN, works against MongoDB's actual design strength. Multi-document transactions have real overhead compared to MongoDB's native single-document atomicity, and MongoDB's own official guidance is: model your data so related information that changes together lives in one document wherever reasonably possible, and reserve multi-document transactions for genuinely cross-document, cross-collection operations (like the bank transfer example) that can't be modeled any other way — not as a universal default tool.

LimitationDetail
Default 60-second limitA transaction running longer than transactionLifetimeLimitSeconds (default 60s) is automatically aborted.
DDL inside a transaction is narrowCreating a collection or index inside a transaction only works in specific cases (MongoDB 4.4+): read concern must be "local", it can't be a cross-shard write, and the target collection must not already exist outside this transaction. Outside those conditions it fails — safest default is still creating collections/indexes ahead of time, outside any transaction.
Requires driver version supportOlder application driver versions don't support the transaction session API at all.

Want a visual for this concept?

Generate a diagram tailored to “MongoDB Transactions” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Why Local Transactions Fail← Back to all Transaction Mastery chapters