Multi-document transactions
Sometimes a single operation genuinely has to change several documents, and either all of them must succeed or none of them may. The textbook case is moving money: debit one account and credit another. If the debit lands but the credit fails, money has vanished. When the documents involved cannot be merged into one — because they are separate accounts, or live in separate collections — MongoDB offers a real multi-document transaction. It gives you the full ACID guarantee across every operation inside it: they all commit together or they all roll back together.
The price of that guarantee is that a transaction needs a session to run in, requires a replica set (a standalone server cannot run one), and costs more than an ordinary write. So this is a deliberate tool, not a default.
A transaction lives in a session
Section titled “A transaction lives in a session”The flow is always the same. You start a session, then run your operations inside a transaction tied to that session. The driver helpers called withTransaction wrap the whole thing: they begin the transaction, run your callback, commit if it returns cleanly, and abort (rolling everything back) if it throws. They also retry the commit automatically on certain transient errors, which is why you should prefer them over starting and committing by hand.
flowchart TD
S["startSession"] --> B["Begin transaction"]
B --> O1["Debit the source account"]
O1 --> O2["Credit the destination account"]
O2 --> C{"Both succeeded?"}
C -->|"Yes"| COMMIT["Commit — both changes become visible together"]
C -->|"No"| ABORT["Abort — every change is rolled back"] Transferring between accounts
Section titled “Transferring between accounts”Here we move three units of value from Ada to Linus across two documents in an accounts collection. Every operation passes the session, which is what binds it to the transaction. If either update fails — say the source has insufficient balance and we throw — the whole transfer is rolled back and neither balance changes:
const session = db.getMongo().startSession();session.withTransaction(() => { const accounts = session.getDatabase("library").accounts; accounts.updateOne({ name: "Ada" }, { $inc: { balance: -3 } }); accounts.updateOne({ name: "Linus" }, { $inc: { balance: 3 } });});session.endSession();const session = client.startSession();try { await session.withTransaction(async () => { const accounts = client.db("library").collection("accounts"); await accounts.updateOne( { name: "Ada" }, { $inc: { balance: -3 } }, { session } ); await accounts.updateOne( { name: "Linus" }, { $inc: { balance: 3 } }, { session } ); });} finally { await session.endSession();}with client.start_session() as session: def transfer(s): accounts = client.library.accounts accounts.update_one( {"name": "Ada"}, {"$inc": {"balance": -3}}, session=s ) accounts.update_one( {"name": "Linus"}, {"$inc": {"balance": 3}}, session=s )
session.with_transaction(transfer)session, err := client.StartSession()if err != nil { return err}defer session.EndSession(ctx)
_, err = session.WithTransaction(ctx, func(sc mongo.SessionContext) (interface{}, error) { accounts := client.Database("library").Collection("accounts") if _, err := accounts.UpdateOne(sc, bson.M{"name": "Ada"}, bson.M{"$inc": bson.M{"balance": -3}}); err != nil { return nil, err } if _, err := accounts.UpdateOne(sc, bson.M{"name": "Linus"}, bson.M{"$inc": bson.M{"balance": 3}}); err != nil { return nil, err } return nil, nil})if err != nil { return err}let mut session = client.start_session().await?;let accounts = client .database("library") .collection::<Document>("accounts");
session .start_transaction() .and_run((), |session, _| { let accounts = accounts.clone(); async move { accounts .update_one(doc! { "name": "Ada" }, doc! { "$inc": { "balance": -3 } }) .session(&mut *session) .await?; accounts .update_one(doc! { "name": "Linus" }, doc! { "$inc": { "balance": 3 } }) .session(&mut *session) .await?; Ok(()) } .boxed() }) .await?;Before the transfer, the two accounts stand like this:
[ { "_id": 1, "name": "Ada", "balance": 10 }, { "_id": 2, "name": "Linus", "balance": 4 }]After a successful commit, the three units have moved and the total is preserved. No reader ever saw a moment where Ada’s three units had left her but not yet arrived for Linus:
[ { "_id": 1, "name": "Ada", "balance": 7 }, { "_id": 2, "name": "Linus", "balance": 7 }]In Compass: Compass does not drive transactions from its document editor, but you can open the mongosh tab embedded at the bottom of the window and run the session-based script there against the same connection.
Tips and gotchas
Section titled “Tips and gotchas”- Transactions require a replica set. A single standalone
mongodcannot run one. The local Docker setups in this course already run a one-node replica set so you can try these examples. - Keep transactions short. A transaction holds resources for its whole lifetime and has a default time limit. Do only the writes that must be atomic inside it, and do slow work — network calls, heavy computation — outside.
- There is a real performance cost. Transactions are slower than the equivalent single-document writes, and they can abort under write conflicts and need retrying. Use them where correctness demands it, not as a comfort blanket.
- Pass the session everywhere. An operation inside the callback that forgets to pass the session runs outside the transaction and will not be rolled back.