Backend / Databases / NOSQL / MongoDB / 04_transactions_concerns.md

MongoDB — Transactions, Read & Write Concerns

Updated 6 interview angles 6 min read source
On this page14
  1. Single-document atomicity (the default)
  2. Multi-document transactions
  3. Retry pattern
  4. When NOT to use multi-doc transactions
  5. Write concerns
  6. Read concerns
  7. Read preferences
  8. Causal consistency
  9. Combining concerns
  10. Optimistic concurrency
  11. Sessions
  12. Common gotchas
  13. Related
  14. Interview angle

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.

python
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

python
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

python
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 acks alone; rolled back if it fails before replicating
w: "majority" majority of members ack (default since 5.0)
w: <N> N replicas ack
j Meaning
j: false no journal wait (w: "majority" journals anyway by default)
j: true wait for the write to be journaled (persisted to disk) before ack
python
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
python
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
python
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:

python
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 (causal in session); 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”:

python
# 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.

python
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 to w: "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. Use primaryPreferred or causal sessions.
  • Retry-able writes don’t help non-idempotent operations. $inc is idempotent (in retry context); arbitrary aggregation pipelines aren’t.

Interview angle 6

  • “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: 1 vs w: \"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 with readConcern: \"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.