backend / databases / nosql / mongodb / 04_transactions_concerns.md

MongoDB — Transactions, Read & Write Concerns

6 interview angles 6 min read source

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 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

  • “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.