Atomic Multi-Collection Writes with Validated Transactions
Committing a write to two collections as one indivisible unit — debit an accounts document and append a matching ledger entry, or never touch either — is the transactional guarantee this page delivers, extending the enforcement toolkit in cross-collection validation patterns that sits under the MongoDB JSON Schema Validation Architecture. A $jsonSchema validator still fires on each individual write inside a transaction, but it can only judge one document at a time; the invariant that both documents land or neither does is enforced by the transaction boundary, not the schema. The reader outcome is a production-grade pattern: a session-scoped transaction that writes across collections, keeps every per-document validator in force, retries correctly on the transient-error labels MongoDB defines, and commits under majority write concern so the result survives a primary election.
Operational Mechanics and Write-Path Impact
A MongoDB multi-document transaction groups a sequence of writes so they commit atomically and are isolated from other transactions until commit. It does not suspend validation. Every insert, update, and replace issued inside the transaction passes through the same collection validator it would outside one — so a document that violates its collection’s $jsonSchema raises WriteError code 121 at the moment of that write, and because the failure occurs inside the transaction, it aborts the entire transaction. The debit and the ledger entry both roll back. This is the key interaction: schema validation and transactional atomicity compose cleanly, with the validator guarding each document’s shape and the transaction guarding the all-or-nothing relationship between them.
Two correctness requirements are non-negotiable. First, retry on the labelled transient errors. MongoDB attaches error labels to transaction failures: TransientTransactionError means the whole transaction can be safely retried from the beginning, and UnknownTransactionCommitResult means the commit specifically should be retried because its outcome is unknown. Driver helpers such as session.with_transaction() implement both retry loops for you. Second, commit with majority write concern so an acknowledged commit is durable across a failover; a commit acknowledged only by the primary can be rolled back if that primary steps down before replicating.
| Mechanism | Guarantees | Does not guarantee |
|---|---|---|
Per-document $jsonSchema validator |
Each written document’s shape; a code 121 aborts the transaction |
Any relationship between the two documents |
| Transaction boundary | Both writes commit or neither does; snapshot isolation | Document shape — that is still the validator’s job |
majority write concern on commit |
Durability across a primary election | Anything if the commit is never retried after UnknownTransactionCommitResult |
Because the atomic unit spans collections, this pattern is also the enforcement engine behind cross-collection guards — the existence-checked insert described in enforcing referential integrity across collections is exactly a two-statement transaction of this shape.
Exact Diagnostic Fingerprints and Fast Resolution
Transaction failures fall into a compact set of signatures, each with a different correct response — and retrying the wrong one wastes work or corrupts state. Match precisely:
| Signature | Where it appears | Root cause | Resolution |
|---|---|---|---|
errorLabels contains TransientTransactionError |
Driver, during a write or commit | Transient conflict or election; whole transaction is safe to retry | Retry the entire transaction from the first statement |
errorLabels contains UnknownTransactionCommitResult |
Driver, at commit | Commit sent but acknowledgement lost | Retry the commit only, not the writes |
WriteError code 121 (DocumentValidationFailure) |
Driver, on a write inside the txn | One document failed its collection $jsonSchema |
Transaction aborts atomically; fix the document, do not retry blindly |
OperationFailure code 112 (WriteConflict) |
Driver, during a write | Concurrent transaction touched the same document | Carries the transient label; retry the whole transaction |
OperationFailure code 50 (MaxTimeMSExpired) / transaction expiry |
Driver, ~60s in | Transaction exceeded transactionLifetimeLimitSeconds |
Shorten the transaction; move non-essential work outside it |
The transient and commit labels are the branch points of every robust transaction loop. Inspect them explicitly when you drive commits by hand:
from pymongo.errors import OperationFailure
try:
session.commit_transaction()
except OperationFailure as exc:
if exc.has_error_label("UnknownTransactionCommitResult"):
pass # safe to retry the COMMIT only
elif exc.has_error_label("TransientTransactionError"):
pass # safe to retry the WHOLE transaction
else:
raise # e.g. a code 121 validation failure — do not retry
A code 121 inside a transaction is deterministic — the same document will fail identically on every retry — so it must break the retry loop, exactly as a validation failure does on the ordinary write path described in the parent architecture.
Step-by-Step Playbook
The pattern begins a session, writes to each collection in turn, and commits with a retry loop that honours the error labels. The managed with_transaction() helper is the recommended form because it encapsulates both retry loops.
1. Begin a session and start the transaction. A transaction lives inside a session; set the read and write concerns on the transaction so the commit is durable:
from pymongo import MongoClient
from pymongo.read_concern import ReadConcern
from pymongo.write_concern import WriteConcern
client = MongoClient("mongodb://localhost:27017/?replicaSet=rs0")
db = client.bank
2. Write to collection A, then collection B. Both writes go through the session and each is validated by its own collection’s $jsonSchema. Wrapping them in a callback lets the managed helper retry the whole unit:
def transfer(session, amount, account_id, ref):
"""Debit an account and append a ledger entry as one atomic unit."""
db.accounts.update_one(
{"_id": account_id, "balance": {"$gte": amount}},
{"$inc": {"balance": -amount}},
session=session, # validator on accounts still fires
)
db.ledger.insert_one(
{"account_id": account_id, "delta": -amount, "ref": ref},
session=session, # validator on ledger still fires
)
# A code 121 on either write aborts the whole transaction automatically.
3. Commit with retry under majority. with_transaction() runs the callback, retries the whole transaction on TransientTransactionError, and retries the commit on UnknownTransactionCommitResult — the two loops you would otherwise hand-roll:
with client.start_session() as session:
session.with_transaction(
lambda s: transfer(s, amount=250, account_id="acct-1", ref="txn-9001"),
read_concern=ReadConcern("snapshot"),
write_concern=WriteConcern("majority"),
)
# Returns only when both writes are committed and majority-acknowledged.
Failure Modes & Rollback
Rollback in a transaction is automatic and total — that is the entire point. Any error before commit leaves the database exactly as it was; there is no compensating write to author because nothing was persisted. The failure modes are about recognising which error you have:
- A code
121validation failure (step 2). One document violated its collection validator. The transaction aborts atomically — both the debit and the ledger entry vanish — with a time-to-recover of zero, because no state changed. Do not retry; the document is deterministically invalid. Fix its shape and re-submit, or route it through fallback routing for invalid documents. - A
WriteConflict(code112, step 2). A concurrent transaction touched the same document. It carriesTransientTransactionError, sowith_transaction()retries the whole unit transparently; hand-rolled loops must retry from the first statement, never just the failed write. - Transaction timeout (step 3). The default
transactionLifetimeLimitSecondsis 60; a transaction that holds locks longer is aborted server-side with code50and again rolls back completely. Keep transactions short — do only the coordinated writes inside them and move reporting, fan-out, or third-party calls outside. - Lost commit acknowledgement (step 3). An
UnknownTransactionCommitResultlabel means the commit may or may not have applied. Retrying the commit is safe and idempotent; retrying the writes is not. This is precisely the distinctionwith_transaction()encodes for you.
If you must abort explicitly — for a failed business rule such as an insufficient balance — call session.abort_transaction(); it discards every write in the transaction and returns write availability immediately, with recovery bounded only by replication lag to your slowest secondary.
Frequently Asked Questions
Do collection $jsonSchema validators still run on writes inside a transaction?
Yes. A transaction changes atomicity and isolation, not validation. Every insert, update, and replace inside it passes through the same collection validator it would on the ordinary write path, and a violation raises WriteError code 121 at that write. Because the failure happens inside the transaction, it aborts the entire transaction, so all coordinated writes roll back together — the validator guards shape while the transaction guards the all-or-nothing relationship.
What is the difference between retrying on TransientTransactionError and UnknownTransactionCommitResult?
TransientTransactionError means the whole transaction failed cleanly and can be re-run from its first statement — a WriteConflict is the common case. UnknownTransactionCommitResult means the commit was sent but its acknowledgement was lost, so only the commit should be retried, not the writes, because the transaction may already have applied. The with_transaction helper implements both loops; a code 121 carries neither label and must not be retried.
Why commit with majority write concern instead of the default?
A commit acknowledged only by the primary can be rolled back if that primary steps down before the write replicates, silently undoing a transaction your application believes succeeded. Committing with w majority means the acknowledgement waits until a majority of the replica set has the write, so it survives an election. It is the write concern that makes an atomic multi-collection commit genuinely durable.
Related
- Cross-Collection Validation Patterns — the parent workflow placing transactional atomicity among the cross-collection enforcement options.
- MongoDB JSON Schema Validation Architecture — the section reference for how per-document validators behave on the write path a transaction wraps.
- Enforcing Referential Integrity Across MongoDB Collections — the existence-guard use of exactly this transaction shape.
- Validating Document References with $lookup — the read-side detection that finds relationships a missing transaction failed to keep atomic.
- Fallback Routing for Invalid Documents — where a document that aborts a transaction with code 121 is quarantined for remediation.