Multi-document ACID transactions — when you need them and how to use them.
Published September 21, 2026
A transaction groups several writes so they succeed or fail together. If any step fails, none of the changes become visible. That's the "A" (atomicity) in ACID.
In MongoDB the first thing to know is that every write to a single document is already atomic, including updates that change several fields, push into arrays and modify nested sub-documents. That's why well-designed MongoDB schemas need multi-document transactions much less often than relational ones: data that must change together is usually stored together in one document.
Multi-document transactions (MongoDB 4.0 for replica sets, 4.2 for sharded clusters) cover the remaining cases, where one business operation must update several documents or collections all-or-nothing.
When you don't need one:
mongod. For local development, run a single-node replica set, or use Testcontainers' MongoDBContainer, which starts one.MongoTransactionManager bean. Without it, @Transactional silently does nothing for MongoDB operations.@Configuration
public class MongoConfig {
@Bean
MongoTransactionManager transactionManager(MongoDatabaseFactory factory) {
return new MongoTransactionManager(factory);
}
}
@Transactional@Service
@RequiredArgsConstructor
public class TransferService {
private final MongoTemplate mongo;
@Transactional
public void transfer(String fromId, String toId, BigDecimal amount) {
// Debit only if the balance is sufficient — the condition and the change are one atomic update
UpdateResult debit = mongo.updateFirst(
Query.query(Criteria.where("_id").is(fromId).and("balance").gte(amount)),
new Update().inc("balance", amount.negate()),
Account.class);
if (debit.getModifiedCount() == 0) {
// Throwing rolls back everything done in this transaction
throw new InsufficientFundsException(fromId);
}
mongo.updateFirst(
Query.query(Criteria.where("_id").is(toId)),
new Update().inc("balance", amount),
Account.class);
mongo.insert(new LedgerEntry(fromId, toId, amount, Instant.now()));
}
}
The check on getModifiedCount() is essential. An updateFirst whose filter matches nothing is not an error: it simply updates zero documents. Without that check, an account with too little money would silently not be debited, the credit would still happen, and money would be created from nothing. Always verify that each step actually did what the business operation needs, and throw to roll back if not.
The same rules as for any Spring @Transactional apply: the method must be called through the Spring proxy (not from another method in the same class), and by default only unchecked exceptions trigger a rollback.
MongoDB transactions use optimistic concurrency. If two transactions modify the same document, one gets a WriteConflict error and is aborted. The driver labels such errors TransientTransactionError, meaning "safe to retry the whole transaction". Commits interrupted by network issues are labelled UnknownTransactionCommitResult, which means retry the commit. The callback API (session.withTransaction(...)) and Spring's TransactionTemplate handle these retries for you. With plain @Transactional you may want a retry around the whole method for hot documents.
// Explicit retries with the driver's callback API
try (ClientSession session = mongoClient.startSession()) {
session.withTransaction(() -> {
accounts.updateOne(session, eq("_id", fromId), inc("balance", amount.negate()));
accounts.updateOne(session, eq("_id", toId), inc("balance", amount));
return null;
}, TransactionOptions.builder()
.readConcern(ReadConcern.SNAPSHOT)
.writeConcern(WriteConcern.MAJORITY)
.build());
}
writeConcern: majority means the commit is acknowledged only once most replica-set members have it, so a primary failover can't lose a committed transaction. That's what you want for financial data.
transactionLifetimeLimitSeconds). Keep transactions short, and never call external services or wait for user input inside one.Before reaching for a transaction, ask whether the schema can make the operation a single-document update:
order_lines collection.$inc, $push, conditional filters like balance >= amount) instead of read-modify-write.When the data genuinely has to live in separate documents or services, a saga (a sequence of local steps with compensating actions) is often better than a long distributed transaction, especially across microservices. That's covered in Failure Scenario Walkthroughs.
Q: Why doesn't @Transactional work in my local MongoDB setup?
A: Two usual causes. There's no MongoTransactionManager bean, so Spring has nothing to manage MongoDB transactions with and the annotation does nothing. Or the server is a standalone mongod, which doesn't support transactions at all. Run a single-node replica set locally, or use Testcontainers.
Q: Are MongoDB transactions fully ACID?
A: Yes, multi-document transactions provide atomicity, snapshot isolation for reads, and durability when committed with writeConcern: majority. The practical differences from relational databases are in cost and limits (runtime, size), and in the fact that good MongoDB schemas are designed to need them rarely.
Q: What isolation level do MongoDB transactions give?
A: With readConcern: snapshot, reads inside the transaction see a consistent snapshot taken at the start, which is similar to snapshot isolation in relational databases. Concurrent writes to the same document cause a write conflict for one of the transactions, which then retries. You don't get lost updates between transactions.
Q: Would you use transactions to keep MongoDB and Kafka in sync? A: No. A MongoDB transaction can't include a Kafka publish. The standard answer is the transactional outbox: write the business change and an "event to publish" document in the same MongoDB transaction (or the same document), then a separate process (or change streams) publishes outbox events to Kafka and marks them sent. That gives at-least-once publishing without dual-write inconsistencies.