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.

Routing a cross-collection write to one of three enforcement patterns A cross-collection write first passes the single-document $jsonSchema validator, whose boundary sees only the one incoming document and cannot read the reference collection. The write is then routed by its consistency requirement: a strong-consistency need selects Pattern 1, a pre-flight transaction that reads the reference under snapshot isolation and inserts in one atomic commit; a high-throughput need selects Pattern 2, which writes immediately and reconciles orphans through a Change Stream into a quarantine collection; and complex or versioned rules select Pattern 3, an application-layer mediator that caches reference lookups and returns a structured error map. Route each write by its consistency need Cross-collection write Single-document boundary $jsonSchema one document only Reference collection no $lookup Consistency requirement? Pattern 1 — Pre-flight transaction snapshot read + insert, one atomic commit no orphan window · ledgers, inventory Pattern 2 — Async reconciliation write now · Change Stream reconciles orphans → quarantine collection Pattern 3 — App-layer mediator cached reference lookups · state checks structured error map · versioned rules strong consistency high throughput complex / versioned rules

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 details object and $jsonSchema-as-query behavior used in the diagnostics assume 5.0+. Change Streams require the WiredTiger storage engine and majority read 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, and has_error_label semantics stay stable across builds.
  • Permissions: the service principal needs find on every reference collection, insert/update on target and quarantine collections, and — for the governance pipeline only — the collMod action (granted by dbAdmin). Cross-collection validation must never require clusterAdmin; keep it least-privilege.
  • Indexing: every reference lookup (find_one/count_documents by _id or 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.

  1. Classify the reference constraint. Decide whether the rule is existence (the referenced _id must exist), state (the reference must be in an allowed status, e.g. an order can only attach to an active account), 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.

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

  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 a validation_quarantine collection.

  4. Register the rule set as versioned configuration. Store validation rules and any server-side $jsonSchema validators 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:

Pattern 1 pre-flight transaction sequence A sequence diagram of Pattern 1. The application starts a session and opens a multi-document transaction under snapshot read concern and majority write concern, then reads the referenced document from the reference collection by its _id. If the reference is missing the transaction is aborted and a CrossCollectionValidationError is raised, so nothing is written. If the reference exists the application inserts the target document and commits the transaction, so the reference check and the write succeed or fail together atomically. App service Reference collection Target collection start_session · begin transaction find_one({_id}, session, snapshot) alt [ reference missing ] null abort_transaction · raise ValidationError [ reference exists ] reference doc insert_one(document, session) commit_transaction

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 $jsonSchema tuning adds referential integrity — $lookup, $ref, and $merge are 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; tune maxTimeMS on 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_quarantine idempotent (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_insert and fall back to a plain insert_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_quarantine back 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 with db.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.