MongoDB — Transactions, Read & Write Concerns
MongoDB transactions are atomic at the single-document level by default. Multi-document transactions (added in 4.0 for replica sets, 4.2 for sharded clusters) bring ACID across multiple operations but at a real performance cost. Use them when you need them; don’t reach for them on every write.
Single-document atomicity (the default)
Every write to a single document is atomic — including updates to nested fields, arrays, etc. Most “transactions” in MongoDB are just single-document updates with $set / $inc / $push / $pull, etc.
db.accounts.update_one(
{"_id": "alice"},
{"$inc": {"balance": -50, "version": 1}},
)
One write, atomic. Don’t reach for a multi-document transaction here.
Multi-document transactions
from pymongo import MongoClient
client = MongoClient("mongodb://localhost:27017", replicaSet="rs0")
with client.start_session() as session:
with session.start_transaction():
db.accounts.update_one(
{"_id": "alice"}, {"$inc": {"balance": -50}}, session=session
)
db.accounts.update_one(
{"_id": "bob"}, {"$inc": {"balance": 50}}, session=session
)
db.transfers.insert_one(
{"from": "alice", "to": "bob", "amount": 50}, session=session
)
# commits at __exit__
ACID across the three operations. If anything raises inside the with block, the transaction aborts and nothing is persisted.
Caveats:
- Replica set required (single-node won’t do — even a single-member replica set is needed).
- Sharded cluster support since 4.2.
- 60-second default timeout — long transactions get aborted.
- Memory cost — uncommitted changes held until commit.
- WriteConflict errors under contention — retry on
TransientTransactionError.
Retry pattern
from pymongo.errors import OperationFailure
def transfer(from_id, to_id, amount):
while True:
with client.start_session() as session:
try:
with session.start_transaction():
do_transfer(session, from_id, to_id, amount)
return
except OperationFailure as e:
if e.has_error_label("TransientTransactionError"):
continue # retry
raise
MongoDB explicitly tags transient errors with TransientTransactionError; retry the whole transaction. Don’t retry on permanent failures.
When NOT to use multi-doc transactions
- The write spans a single document → use atomic field updates.
- You can model the operation as one document → restructure (embed the related data).
- The operation is idempotent / can be replayed → optimistic concurrency with version numbers might suffice.
Pattern: prefer single-doc atomicity by restructuring data. Multi-doc transactions are a tool of last resort, especially in sharded clusters where they’re expensive.
Write concerns
Controls when a write is acknowledged as successful.
w |
Meaning |
|---|---|
w: 0 |
fire-and-forget; no acknowledgment |
w: 1 |
primary writes to memory and acks (default) |
w: "majority" |
majority of replica set members ack |
w: <N> |
N replicas ack |
j |
Meaning |
|---|---|
j: false |
no fsync requirement (default) |
j: true |
wait for the write to be journaled (persisted to disk) before ack |
db.orders.insert_one(
{"...": "..."},
write_concern={"w": "majority", "j": True, "wtimeout": 5000}
)
Trade-offs
| Concern | Durability | Latency |
|---|---|---|
w: 0 |
minimal — no ack means you don’t know it landed | lowest |
w: 1 |
primary memory ack; node crash before fsync loses it | low |
w: 1, j: true |
primary journaled to disk | medium |
w: "majority" |
majority of replicas have it; survives primary failover | higher |
w: "majority", j: true |
majority + journaled; safe against most disasters | highest |
For anything load-bearing (financial, audit, etc.): w: "majority". For analytics ingestion where you can replay on failure: w: 1 or w: 0.
Read concerns
Controls visibility of writes by other operations.
readConcern |
Reads … |
|---|---|
local |
latest data on this node; may be rolled back if it wasn’t replicated (default) |
available |
similar to local on sharded, with weaker guarantees |
majority |
only data that’s been acknowledged by a majority — durable |
linearizable |
linearizable per document; the strongest, slowest; primary only |
snapshot |
consistent snapshot across replica set; for multi-doc transactions |
db.orders.find_one(
{"_id": order_id},
read_concern={"level": "majority"}
)
Why majority matters
local reads can return data that the primary hadn’t yet replicated — if that primary fails and a different node is elected, the data is gone. Read-your-write consistency across failovers requires majority.
For financial / regulatory reads: majority. For dashboards / non-critical: local.
Read preferences
Which member of the replica set serves reads:
| Behavior | |
|---|---|
primary (default) |
reads always from primary |
primaryPreferred |
primary, but fall back to secondary |
secondary |
only secondaries |
secondaryPreferred |
secondary if available, else primary |
nearest |
lowest network latency |
db.read_preference = ReadPreference.SECONDARY_PREFERRED
Sending reads to secondaries scales read throughput but introduces lag (secondaries are eventually consistent with the primary). Combine with readConcern: "majority" to get bounded staleness.
Causal consistency
Within a session, you can guarantee causal order even across primary/secondary reads:
with client.start_session(causal_consistency=True) as session:
db.users.insert_one({"_id": "alice"}, session=session)
user = db.users.find_one({"_id": "alice"}, session=session)
# guaranteed to see the just-inserted doc
Without causal consistency, a subsequent read on a secondary might lag and miss the write.
Combining concerns
Practical defaults for different workloads:
| Workload | writeConcern | readConcern | readPreference |
|---|---|---|---|
| Financial / audit | majority, j:true |
majority |
primary |
| User-facing reads | majority |
majority (within session via causal) |
primaryPreferred |
| Analytics ingestion | 1 |
local |
primary |
| Dashboards / reports | (no writes) | available |
secondary |
| Bulk import | 0 (fire-and-forget) |
n/a | primary |
Optimistic concurrency
For “update if not changed since last read”:
# Read with current version
order = db.orders.find_one({"_id": order_id})
# Update conditionally
result = db.orders.update_one(
{"_id": order_id, "version": order["version"]},
{"$set": {"status": "completed"}, "$inc": {"version": 1}}
)
if result.matched_count == 0:
# Concurrent update — retry
raise ConcurrencyError
Cheaper than a transaction; works well for “update X if it’s still in state Y.”
Sessions
Required for transactions and causal consistency. Lightweight — start one per logical operation.
with client.start_session() as session:
...
Sessions also enable retryable writes (default since 3.6) — if a write fails due to a network blip, the driver auto-retries idempotently.
Common gotchas
- Default
w: 1— primary memory ack only. Primary crash before fsync loses the write. For critical writes, bump tow: "majority". - Transactions on single-node — fail. Need replica set (even single-member).
- Long transactions — 60-second default timeout. Don’t do bulk work inside a transaction; break it up.
- Sharded transactions are slow. All shards involved pay the coordination cost. Restructure if possible.
- Mixing read preference and write concern.
secondaryPreferred+w: "majority"reads can see different data than you just wrote. UseprimaryPreferredor causal sessions. - Retry-able writes don’t help non-idempotent operations.
$incis idempotent (in retry context); arbitrary aggregation pipelines aren’t.
Interview angle
- “When do you actually need a multi-document transaction?” — when the operation truly spans multiple documents that can’t be merged into one. Single-document atomicity covers ~90% of cases via
$set/$inc/$push. Transactions are a fallback, not the default. - “
w: 1vsw: \"majority\"?” —w: 1: primary memory ack; fast but lost on primary failure.w: \"majority\": replicated to majority; survives failover. Critical writes use majority. - “
readConcern: \"local\"vs\"majority\"?” — local: latest on this node, may be rolled back on primary failover. Majority: only data confirmed by majority of replicas; survives failover. For read-your-write across failovers, majority is required. - “How do you scale reads to secondaries?” —
readPreference: \"secondaryPreferred\". Secondaries lag (asynchronous replication); combine withreadConcern: \"majority\"for bounded staleness, or use causal consistency within a session for “read your own writes” guarantees. - “What’s a
TransientTransactionError?” — MongoDB-tagged error indicating the transaction failed due to a transient condition (write conflict, network blip). Retry the whole transaction. Don’t retry on permanent errors. - “Alternative to a transaction for simple race conditions?” — optimistic concurrency: read the document with its version, update conditionally on
(id, version)matching, retry on mismatch. Cheaper than a transaction; effective for single-document conflicts.