Cross-Collection Validation Patterns
Within the broader MongoDB JSON Schema Validation Architecture, this guide addresses the one integrity guarantee that a $jsonSchema validator structurally cannot provide: consistency that spans more than one collection. MongoDB evaluates every validator atomically against the single incoming document, so referential integrity, cross-entity business rules, and state-machine transitions that depend on a second collection must be enforced by deliberate application-layer patterns instead. This page is a complete implementation workflow for platform engineers and Python automation builders: it defines where cross-collection checks sit in the write path, gives you three production-ready enforcement patterns (strongly-consistent pre-flight, eventually-consistent reconciliation, and an application-layer mediator), and supplies the diagnostic, verification, and rollback commands to operate them. The deliverable is a set of runnable PyMongo classes you can drop into a service, a Change Stream worker, or a CI/CD governance job — each with explicit failure routing so a cross-collection violation is never silently dropped.
Architectural Context & Enforcement Boundaries
The foundational constraint is that MongoDB’s validator does not execute $lookup, $merge, or any cross-collection query during an insert, update, or replace. Rules defined via collMod or createCollection see only the document being written. This is deliberate — it keeps validation O(1) on the write path and free of cross-shard coordination — but it means the collection-level validator, the innermost synchronous gate described in implementing collection-level validators, can guarantee a document’s shape but never that its customerId actually points at a live customer.
Cross-collection integrity is therefore a distributed-systems problem, not a database configuration task. The design axis is the consistency model. Strong consistency requires transactional coordination and pays a latency and lock-contention cost on every write; eventual consistency favors availability and throughput but opens a reconciliation window during which orphaned references exist. The three patterns below sit at different points on that axis, and most production systems combine them — a synchronous check on the critical financial path, asynchronous reconciliation for high-volume telemetry, and an application mediator for polyglot rules. Documents that fail any of these checks should be diverted, not discarded: route them through fallback routing for invalid documents so a rejection becomes a remediation task rather than data loss.
The keyword $jsonSchema is also a valid query operator, which is what makes several of the diagnostics below possible: you can count documents that would fail a schema, or find orphaned references, without any validator being active. For the keyword surface those schemas are built from, see understanding MongoDB $jsonSchema syntax.
Prerequisites & Operational Requirements
Every pattern here targets a supported replica-set topology. Confirm the following before wiring any of them into a live service.
- MongoDB version: 5.0 or later. Multi-document transactions require a replica set (or sharded cluster) on 4.0+, but the rich validation
detailsobject and$jsonSchema-as-query behavior used in the diagnostics assume 5.0+. Change Streams require the WiredTiger storage engine andmajorityread concern support. - Topology: a replica set, never a standalone. Transactions and Change Streams both depend on the oplog; neither is available on a standalone
mongod. - Driver: PyMongo 4.x (
pip install "pymongo>=4.6,<5"). Pin the driver in your automation image so session, transaction, andhas_error_labelsemantics stay stable across builds. - Permissions: the service principal needs
findon every reference collection,insert/updateon target and quarantine collections, and — for the governance pipeline only — thecollModaction (granted bydbAdmin). Cross-collection validation must never requireclusterAdmin; keep it least-privilege. - Indexing: every reference lookup (
find_one/count_documentsby_idor by a foreign key) must be backed by an index. An unindexed lookup on the reference collection turns each validated write into a collection scan.
The two enforcement dials you will reuse from single-collection validation are validationLevel and validationAction; their interaction governs which writes MongoDB itself checks before your cross-collection logic even runs:
validationAction |
validationLevel |
Checks applied to | On single-doc failure |
|---|---|---|---|
warn |
moderate |
Inserts + updates to already-valid docs | Logs a warning; write succeeds |
warn |
strict |
All inserts and updates | Logs a warning; write succeeds |
error |
moderate |
Inserts + updates to already-valid docs | Rejects with WriteError code 121 |
error |
strict |
All inserts and updates | Rejects with WriteError code 121 |
Use these dials to reject syntactically malformed payloads at the collection boundary before they reach your cross-collection layer — the trade-offs are covered under strict vs moderate validation levels. Cross-collection logic should only ever run on documents that already passed their own shape contract.
Idempotent Implementation Workflow
Selecting and deploying a pattern is a deterministic sequence. Follow these steps; each is independently verifiable.
-
Classify the reference constraint. Decide whether the rule is existence (the referenced
_idmust exist), state (the reference must be in an allowed status, e.g. an order can only attach to anactiveaccount), or cardinality (a bounded number of children). Existence and state checks are cheap point lookups; cardinality checks require an aggregate and are best handled asynchronously. -
Pick the consistency model. If a stale or orphaned reference is unacceptable — ledgers, inventory allocation, entitlement checks — use Pattern 1 (pre-flight transaction). If the write volume cannot absorb per-write transaction overhead and a short reconciliation window is tolerable, use Pattern 2. If the rule set is large, versioned, or spans external systems, front it with Pattern 3.
-
Deploy the pattern with explicit failure routing. Never let a cross-collection failure raise an unhandled exception that a retry loop silently swallows. Each pattern below routes failures to a typed exception (
CrossCollectionValidationError) or avalidation_quarantinecollection. -
Register the rule set as versioned configuration. Store validation rules and any server-side
$jsonSchemavalidators in version control and apply them idempotently (Governance section). Align the versioning of these artifacts with your schema versioning strategies for NoSQL so cross-collection contracts evolve auditably.
For Pattern 1, the transactional pre-flight check reads the reference under snapshot isolation and commits the primary write in the same transaction, so no window exists in which the target document references a missing parent:
Production-Ready Automation Implementation
The three implementations below are complete and runnable. They share one typed exception so that callers can distinguish a genuine referential failure from an infrastructure error:
class CrossCollectionValidationError(Exception):
"""Raised when a document violates a cross-collection referential or state rule."""
pass
Pattern 1 — Pre-flight transactional validation
For strong consistency, the reference existence check and the primary write execute inside one multi-document transaction under snapshot read concern and majority write concern. This guarantees atomicity — the document is never persisted while its reference is missing — at the cost of write latency and lock contention on the reference collection. It suits financial ledgers, inventory allocation, and compliance-critical state transitions. Transient transaction aborts (labeled TransientTransactionError) are expected under concurrency and are retried with bounded exponential backoff; genuine referential failures are raised immediately and never retried.
import logging
import time
from typing import Any, Dict
from pymongo import MongoClient
from pymongo.read_concern import ReadConcern
from pymongo.write_concern import WriteConcern
from pymongo.errors import PyMongoError, DuplicateKeyError
logger = logging.getLogger(__name__)
class TransactionalValidator:
def __init__(self, client: MongoClient, db_name: str):
self.client = client
self.db = client.get_database(
db_name,
read_concern=ReadConcern("snapshot"),
write_concern=WriteConcern("majority"),
)
def validate_and_insert(
self,
target_collection: str,
document: Dict[str, Any],
reference_collection: str,
reference_field: str,
max_retries: int = 3,
) -> str:
"""Atomically verify a reference exists, then insert. Retries transient aborts."""
ref_value = document.get(reference_field)
if ref_value is None:
raise ValueError(f"Missing required reference field: {reference_field}")
for attempt in range(1, max_retries + 1):
with self.client.start_session() as session:
try:
with session.start_transaction(
read_concern=ReadConcern("snapshot"),
write_concern=WriteConcern("majority"),
):
# Step 1: verify the reference under snapshot isolation.
ref_doc = self.db[reference_collection].find_one(
{"_id": ref_value}, {"_id": 1}, session=session
)
if not ref_doc:
raise CrossCollectionValidationError(
f"Reference {ref_value} not found in {reference_collection}"
)
# Step 2: commit the primary write in the same transaction.
result = self.db[target_collection].insert_one(document, session=session)
session.commit_transaction()
logger.info(
"Inserted %s referencing %s", result.inserted_id, ref_value
)
return str(result.inserted_id)
except (CrossCollectionValidationError, DuplicateKeyError, ValueError):
raise # deterministic failures: never retry
except PyMongoError as exc:
if exc.has_error_label("TransientTransactionError") and attempt < max_retries:
backoff = 2 ** attempt
logger.warning(
"Transient transaction abort %d/%d; retry in %ds",
attempt, max_retries, backoff,
)
time.sleep(backoff)
continue
logger.error("Transaction failed permanently: %s", exc)
raise CrossCollectionValidationError("Transaction failed") from exc
raise CrossCollectionValidationError("Max retries exceeded for transaction")
Index the reference collection on the lookup field and keep max_retries inside your service timeout budget; transient aborts scale with concurrency on the referenced document.
Pattern 2 — Asynchronous event-driven reconciliation
High-throughput ingestion often cannot pay synchronous transaction overhead. Here documents are written immediately (having passed their own single-document validator), and cross-collection integrity is reconciled off the critical path by a Change Stream worker. Orphaned references are routed to a quarantine collection for automated or manual remediation. This favors availability and partition tolerance and pairs naturally with the async validation monitoring dashboards that surface reconciliation lag and quarantine depth.
import logging
from datetime import datetime, timezone
from typing import Any
from pymongo import MongoClient, errors
from pymongo.change_stream import ChangeStream
logger = logging.getLogger(__name__)
class AsyncReconciliationEngine:
def __init__(self, client: MongoClient, db_name: str):
self.client = client
self.db = client[db_name]
def start_stream(
self, watched_collection: str, reference_collection: str, reference_field: str
) -> None:
"""Watch inserts/updates and quarantine documents whose reference does not exist."""
pipeline = [{"$match": {"operationType": {"$in": ["insert", "update"]}}}]
stream: ChangeStream = self.db[watched_collection].watch(
pipeline=pipeline, full_document="updateLookup"
)
try:
for event in stream:
doc = event.get("fullDocument")
if not doc:
continue
ref_value = doc.get(reference_field)
if not ref_value:
continue
try:
self._validate_reference(ref_value, reference_collection)
except CrossCollectionValidationError as exc:
self._route_to_quarantine(doc, str(exc), watched_collection)
except errors.PyMongoError as exc:
logger.critical("Change stream interrupted: %s", exc)
raise
finally:
stream.close()
def _validate_reference(self, ref_value: Any, reference_collection: str) -> None:
exists = self.db[reference_collection].count_documents({"_id": ref_value}, limit=1)
if not exists:
raise CrossCollectionValidationError(f"Orphaned reference detected: {ref_value}")
def _route_to_quarantine(self, document: dict, reason: str, source_collection: str) -> None:
self.db["validation_quarantine"].insert_one({
"original_document": document,
"source_collection": source_collection,
"validation_failure_reason": reason,
"quarantined_at": datetime.now(timezone.utc),
})
logger.warning("Quarantined %s | reason: %s", document.get("_id"), reason)
Persist and resume the Change Stream from a stored resume_token so a worker restart does not skip events, and put a TTL index on validation_quarantine.quarantined_at to bound its growth.
Pattern 3 — Application-layer schema mediator
When rules are numerous, versioned, or span external registries, a mediator validates cross-collection dependencies in the application tier before issuing writes — combining existence and state checks, and caching reference lookups aggressively. It returns a structured error map rather than raising on the first failure, which is what an API layer needs to report every violation at once.
import logging
from dataclasses import dataclass
from typing import Dict, List, Optional
from pymongo import MongoClient
logger = logging.getLogger(__name__)
@dataclass
class ValidationRule:
field: str
target_collection: str
required: bool = True
allowed_states: Optional[List[str]] = None
class SchemaMediator:
def __init__(self, client: MongoClient, db_name: str):
self.client = client
self.db = client[db_name]
self._rules_cache: Dict[str, List[ValidationRule]] = {}
def register_rules(self, collection: str, rules: List[ValidationRule]) -> None:
self._rules_cache[collection] = rules
def validate_payload(self, collection: str, document: dict) -> Dict[str, List[str]]:
"""Return field -> [errors]. Empty dict means the payload passed every rule."""
errors: Dict[str, List[str]] = {}
for rule in self._rules_cache.get(collection, []):
value = document.get(rule.field)
if rule.required and value is None:
errors.setdefault(rule.field, []).append("Missing required field")
continue
if value is None:
continue
try:
ref_doc = self.db[rule.target_collection].find_one(
{"_id": value}, {"_id": 1, "status": 1}
)
if not ref_doc:
errors.setdefault(rule.field, []).append(
f"Reference {value} does not exist in {rule.target_collection}"
)
elif rule.allowed_states and ref_doc.get("status") not in rule.allowed_states:
errors.setdefault(rule.field, []).append(
f"Reference {value} has invalid status: {ref_doc.get('status')}"
)
except Exception as exc:
logger.error("Lookup failed for rule %s: %s", rule.field, exc)
errors.setdefault(rule.field, []).append("Validation service unavailable")
return errors
Cache reference lookups behind an LRU or Redis layer with invalidation hooks tied to Change Streams, and wrap the tier in a circuit breaker so a slow reference collection degrades gracefully instead of cascading.
Governance: idempotent rule deployment & drift detection
Whichever pattern enforces the rule at runtime, the definition of the contract — including any server-side $jsonSchema validators applied at the collection boundary — belongs in version control and must deploy idempotently. This CLI diffs the live validator against the source of truth and only calls collMod when they diverge, so repeated CI/CD runs never take a redundant exclusive lock:
import json
import logging
from pathlib import Path
from pymongo import MongoClient
from pymongo.errors import OperationFailure
logger = logging.getLogger(__name__)
class SchemaGovernanceCLI:
def __init__(self, client: MongoClient, db_name: str):
self.client = client
self.db = client[db_name]
def deploy_validation_rules(self, rules_dir: Path) -> None:
"""Apply {"collection", "validator"} JSON files idempotently via collMod."""
for rule_file in sorted(rules_dir.glob("*.json")):
schema_def = json.loads(rule_file.read_text())
collection_name = schema_def.get("collection")
validator = schema_def.get("validator")
if not collection_name or not validator:
logger.warning("Skipping malformed rule file: %s", rule_file.name)
continue
try:
info = self.db.command("listCollections", filter={"name": collection_name})
batch = info["cursor"]["firstBatch"]
existing = batch[0].get("options", {}).get("validator") if batch else None
if existing == validator:
logger.info("Validator for %s already current; skipping.", collection_name)
continue
self.db.command(
"collMod", collection_name,
validator=validator,
validationLevel="moderate",
validationAction="warn",
)
logger.info("Deployed validator to %s", collection_name)
except OperationFailure as exc:
logger.error("Failed to deploy validator for %s: %s", collection_name, exc)
raise
Deploy in validationLevel: "moderate" / validationAction: "warn" during a rolling release so pre-existing non-compliant documents keep writing while new writes are checked, then promote to strict / error once the compliance rate clears your threshold.
Diagnostic Fingerprints & Fast Resolution
Cross-collection failures surface through a small set of exact signatures. Match on these to route incidents immediately.
| Signature | Root cause | Resolution |
|---|---|---|
pymongo.errors.OperationFailure with has_error_label("TransientTransactionError") |
Write conflict / transient replica-set state during a Pattern 1 transaction. | Retry with backoff (already handled); if persistent, check for a hot referenced document or election churn. |
OperationFailure: Transaction numbers are only allowed on a replica set member or mongos |
Pattern 1 run against a standalone mongod. |
Deploy against a replica set; transactions are unavailable on standalone. |
pymongo.errors.WriteError code 121 (Document failed validation) |
The single-document $jsonSchema validator rejected the write before cross-collection logic ran. |
Inspect exc.details["errInfo"]["details"]["schemaRulesNotSatisfied"]; fix the shape, not the reference. |
CrossCollectionValidationError: Reference … not found |
The referenced _id genuinely does not exist. |
Confirm the parent write ordering; route to fallback/quarantine rather than dropping. |
pymongo.errors.OperationFailure code 286 (ChangeStreamHistoryLost) |
The Pattern 2 resume token aged out of the oplog. | Increase oplog size; rebuild reconciliation state from a full scan, then resume. |
To find orphaned references without a validator active, exploit $jsonSchema-as-query and a two-stage aggregation. This mongosh snippet lists documents in orders whose customerId has no matching customers._id:
db.orders.aggregate([
{ $lookup: {
from: "customers", localField: "customerId",
foreignField: "_id", as: "_ref" } },
{ $match: { _ref: { $eq: [] } } },
{ $project: { _id: 1, customerId: 1 } },
{ $limit: 20 }
])
For a fast quarantine triage from the shell or a log pipeline, count the backlog and group it by reason:
db.validation_quarantine.aggregate([
{ $group: { _id: "$validation_failure_reason", n: { $sum: 1 } } },
{ $sort: { n: -1 } }
])
Edge Cases, Gotchas & Known Limitations
- The validator never crosses the collection boundary. No amount of
$jsonSchematuning adds referential integrity —$lookup,$ref, and$mergeare all rejected inside a validator. Cross-collection rules must live in one of the three patterns above. - Transaction timeouts default to 60 seconds. A Pattern 1 transaction that also does heavy work will hit
transactionLifetimeLimitSeconds. Keep the reference lookup and the write inside the transaction and push everything else outside it; tunemaxTimeMSon high-latency links. - Change Streams have an at-least-once, not exactly-once, delivery model. A Pattern 2 worker can see the same event twice after a resume; make
_route_to_quarantineidempotent (upsert on source_id) so duplicates do not inflate the backlog. - Snapshot reads can still lose a race to deletion outside the transaction. Pattern 1 guarantees the reference existed at snapshot time; if another transaction deletes the parent immediately after commit, you have a dangling reference. Enforce parent-deletion rules (soft-delete or a guard transaction) as the complement.
- Cardinality checks do not belong in the write path. Counting children to enforce a maximum turns every write into an aggregate. Enforce cardinality asynchronously in Pattern 2 and treat a temporary overshoot as a reconciliation event.
- Quarantine collections grow without bound. A TTL index on the timestamp is mandatory; without it a sustained upstream bug fills the disk.
Verification & Rollback Procedures
Confirm each pattern is actually enforcing before you rely on it.
# Pattern 1: a known-bad reference must raise, and must NOT persist a document.
try:
validator.validate_and_insert("orders", {"customerId": "does-not-exist"},
"customers", "customerId")
except CrossCollectionValidationError:
assert db.orders.count_documents({"customerId": "does-not-exist"}) == 0
print("Pattern 1 enforcing: bad reference rejected atomically.")
// Pattern 2: after a deliberate orphan insert, the reconciler must quarantine it.
db.orders.insertOne({ customerId: "orphan-check", amount: 1 })
// ...allow one reconciliation cycle...
db.validation_quarantine.findOne({ "original_document.customerId": "orphan-check" })
Rollback is pattern-specific and fast:
- Pattern 1: no data rollback is needed — an aborted transaction leaves nothing behind. To disable enforcement, stop calling
validate_and_insertand fall back to a plaininsert_one; time-to-recover is a deploy. - Pattern 2: stop the Change Stream worker to halt reconciliation instantly (writes continue unaffected because the pattern never blocks them). Drain
validation_quarantineback into the source collection once the reference data is repaired. - Governance validators: a soft rollback keeps the schema but stops rejecting —
db.runCommand({ collMod: "orders", validationAction: "warn" })— and a hard rollback removes it entirely withdb.runCommand({ collMod: "orders", validator: {}, validationLevel: "off" }). Both are metadata-only and take effect on the primary immediately, propagating through the oplog.
Frequently Asked Questions
Can a MongoDB $jsonSchema validator check a field against another collection?
No. A validator is evaluated atomically against the single document being written and cannot run $lookup, $ref, or any cross-collection query — those keywords are rejected inside a validator. Cross-collection integrity must be enforced in application logic: a pre-flight transaction, an asynchronous Change Stream reconciler, or an application-layer mediator.
Do I need a transaction for every cross-collection check?
Only when a stale or orphaned reference is unacceptable — ledgers, inventory, entitlements. Transactions add write latency and lock contention on the referenced document. For high-throughput ingestion where a short reconciliation window is tolerable, write first and reconcile asynchronously with a Change Stream worker (Pattern 2), routing orphans to a quarantine collection.
What read and write concerns should a pre-flight transaction use?
Use ReadConcern("snapshot") and WriteConcern("majority"). Snapshot isolation prevents a phantom read of the reference document, and majority write concern ensures the committed write survives a replica-set failover. See the MongoDB transactions documentation for the required cluster topology.
How do I find references that are already orphaned before enforcing a rule?
Run a $lookup aggregation with a $match on an empty join result ({ _ref: { $eq: [] } }) to list documents whose foreign key has no matching parent. This needs no validator active and is the standard pre-flight audit before switching a collection to strict cross-collection enforcement.
Why did my Change Stream reconciler stop with ChangeStreamHistoryLost?
The stored resume token aged out of the oplog (error code 286), usually because the worker was down longer than the oplog window. Increase the oplog size, then rebuild reconciliation state from a full scan of the watched collection before resuming, since events in the gap were never processed.
Related
- MongoDB JSON Schema Validation Architecture — the parent reference this cross-collection layer extends, defining the enforcement contract and evaluation pipeline.
- Fallback routing for invalid documents — where a rejected or orphaned reference goes so enforcement never means data loss.
- Strict vs moderate validation levels — tuning the single-document dials that filter payloads before cross-collection checks run.
- Understanding MongoDB
$jsonSchemasyntax — the operator surface for the single-document validators these patterns sit on top of. - Schema versioning strategies for NoSQL — versioning the cross-collection contracts and validators the governance pipeline deploys.
- Async validation monitoring dashboards — surfacing reconciliation lag and quarantine depth for the eventually-consistent pattern.