Enforcing Referential Integrity Across MongoDB Collections

Guaranteeing that every order.customer_id points at a document that actually exists in the customers collection is the precise problem this page solves, and it is the enforcement gap that opens the moment you leave the single-document world of cross-collection validation patterns inside the broader MongoDB JSON Schema Validation Architecture. A $jsonSchema validator inspects exactly one document per write and has no query engine behind it, so it can confirm that customer_id is a well-formed objectId but never that the referenced customer is real. The reader outcome here is a concrete, write-time enforcement mechanism: an existence check fused to the insert inside a single multi-document transaction, plus a scheduled orphan scan that catches anything the write path missed, and the compensating rollback that undoes a half-applied relationship.

Operational Mechanics and Write-Path Impact

The validator runs synchronously on insert, update, replace, and findAndModify, and its entire input is the BSON of the document being written. It cannot issue a find against customers, cannot dereference an objectId, and cannot see any state outside the document image. That is a deliberate design choice — it keeps validation constant-time and shard-local — but it means a foreign-key constraint is simply not expressible in $jsonSchema. The schema below is the strongest referential rule a validator can carry, and it still permits a dangling reference:

db.createCollection("orders", {
  validator: { $jsonSchema: {
    bsonType: "object",
    required: ["customer_id", "status", "total"],
    properties: {
      customer_id: { bsonType: "objectId" },   // shape only — not existence
      status: { enum: ["pending", "paid", "shipped", "cancelled"] },
      total: { bsonType: "decimal", minimum: 0 }
    },
    additionalProperties: false
  }},
  validationLevel: "strict",
  validationAction: "error"
})

Because existence is a cross-document fact, the enforcement point moves off the validator and onto the write itself. The only way to make “the parent exists” and “the child is written” a single indivisible fact is a multi-document transaction: read the parent under the session’s snapshot, and if it is present, insert the child in the same atomic commit. If the read finds nothing, the transaction aborts and the child is never persisted. This closes the race that a naive read-then-write leaves open, where a concurrent delete of the customer between your find and your insert_one would otherwise strand the order. The parallel enforcement mechanics of running that guarded write atomically across two collections are developed in atomic multi-collection writes with transactions.

Enforcement point What it can guarantee What it cannot guarantee
$jsonSchema validator customer_id is a required, well-typed objectId That the objectId resolves to a live customer
Guarded transactional write Parent existed at commit time; child linked atomically Nothing after commit — a later parent delete still orphans
Scheduled orphan scan Detects references broken after the fact Nothing at write time — it is a lagging safety net

Exact Diagnostic Fingerprints and Fast Resolution

Two integrity defects dominate in practice, and they present with different signatures. An orphaned reference is a child whose customer_id never had — or no longer has — a matching parent; you find it by anti-joining orders against customers. A dangling delete is the upstream cause: a customer was removed while orders still pointed at it, converting live references into orphans in bulk. The table maps each symptom to its root cause and first response:

Signature Where it appears Root cause Resolution
Order with customer_id absent from customers Orphan scan $lookup returns non-empty matched array of size 0 Reference written without an existence guard, or race with a concurrent delete Route to quarantine; wrap future writes in the guarded transaction
Sudden spike of orphans sharing recent customer_ids Reconciliation job count jumps A delete_one on customers had no cascade or block Restore or tombstone the parent; add a delete guard
pymongo.errors.OperationFailure code 251 (NoSuchTransaction) Driver, during the guarded write Transaction already aborted (guard failed) before commit Expected on a missing parent; surface a 4xx to the caller, do not retry
WriteError code 121 on the child insert inside the transaction Driver, inside the transaction The child also violated its own $jsonSchema Whole transaction aborts atomically; fix the document shape

To inventory existing damage before changing any write path, run the anti-join directly in the shell. This is read-only and safe on a live system:

// Count orders whose customer_id has no matching customers document.
db.orders.aggregate([
  { $lookup: { from: "customers", localField: "customer_id",
               foreignField: "_id", as: "parent" } },
  { $match: { parent: { $eq: [] } } },
  { $count: "orphaned_orders" }
])
// Expected output: { orphaned_orders: 37 }  — your remediation backlog

The detection pipeline above is the same primitive used for continuous monitoring; the read-side, indexing, and performance mechanics of running it at scale are covered in depth under validating document references with $lookup.

Step-by-Step Playbook

The sequence installs a write-time guarantee first, then backstops it with a periodic scan so that any reference broken outside the guarded path is still caught.

Guarded transactional write plus a scheduled orphan scan safety net An incoming order write starts a client session and opens a transaction. Inside the transaction MongoDB reads the customers collection for the referenced customer_id under snapshot isolation. If the parent exists, the order is inserted and the transaction commits atomically. If the parent is missing, the transaction aborts and nothing is written, and the order is routed to a quarantine collection. Separately and asynchronously, a scheduled orphan scan runs a lookup anti-join across orders and customers to catch any reference broken after commit, feeding survivors into the same quarantine. Order write start_session Read parent customers · snapshot Parent exists? Insert + commit atomic · order linked Abort → quarantine nothing persisted Scheduled orphan scan $lookup anti-join · nightly yes no late orphans

1. Index the foreign key on both sides. The guard reads customers by _id (already indexed) but the orphan scan reads orders by customer_id, so create that index or every scan becomes a collection scan:

db.orders.createIndex({ customer_id: 1 })
// Expected output: customer_id_1

2. Fuse the existence check to the insert in one transaction. Read the parent under the session, and only insert the child if it resolved. The abort path guarantees no orphan is ever committed:

from pymongo import MongoClient
from pymongo.errors import OperationFailure

client = MongoClient("mongodb://localhost:27017/?replicaSet=rs0")
db = client.shop

def create_order_with_integrity(order: dict) -> str:
    """Insert an order only if its customer_id resolves to a live customer."""
    with client.start_session() as session:
        with session.start_transaction():
            parent = db.customers.find_one(
                {"_id": order["customer_id"]}, session=session, projection={"_id": 1}
            )
            if parent is None:
                # Abort: the referenced customer does not exist.
                session.abort_transaction()
                raise ValueError(f"no customer {order['customer_id']}")
            db.orders.insert_one(order, session=session)
            # Commit makes "parent existed" and "order written" one atomic fact.
            return str(order["_id"])

3. Guard deletes so they cannot strand children. A referential system needs the reverse rule too. Before removing a customer, block the delete when live orders reference it, again inside a transaction so the check and delete are atomic:

def delete_customer_safely(customer_id) -> None:
    with client.start_session() as session:
        with session.start_transaction():
            if db.orders.find_one({"customer_id": customer_id}, session=session):
                session.abort_transaction()
                raise ValueError("customer still referenced by orders")
            db.customers.delete_one({"_id": customer_id}, session=session)

4. Schedule an orphan scan as a lagging safety net. No write-time guard covers documents inserted before it existed, or references broken by an out-of-band script. Run the anti-join on a cron and quarantine survivors:

def scan_orphans() -> int:
    pipeline = [
        {"$lookup": {"from": "customers", "localField": "customer_id",
                     "foreignField": "_id", "as": "parent"}},
        {"$match": {"parent": {"$eq": []}}},
        {"$project": {"parent": 0}},
    ]
    orphans = list(db.orders.aggregate(pipeline))
    if orphans:
        db.orders_orphaned.insert_many(orphans, ordered=False)
    return len(orphans)

Failure Modes & Rollback

Each stage fails in a bounded, recoverable way:

  • Guard read races a commit (step 2). Snapshot isolation resolves this — the transaction reads a consistent view, so a parent deleted by a concurrent transaction after your read causes a WriteConflict (code 112) at commit, and the driver retries the whole guarded operation. Never suppress the conflict; it is the mechanism keeping the reference honest.
  • Transaction exceeds the 60-second default (steps 2–3). A guard that fans out to many parents can exceed transactionLifetimeLimitSeconds and abort with code 50 (MaxTimeMSExpired). Keep the guarded read a single indexed _id lookup so the transaction stays sub-millisecond.
  • Orphan scan floods the oplog on cleanup (step 4). Deleting or moving thousands of orphans at once can push a slow secondary off its window. Batch the quarantine writes and throttle between batches.

Rollback is a compensating action, not a schema change. A transaction that has not committed is undone for free by session.abort_transaction() — the child insert simply never happened, which is the cleanest possible rollback. If a bad reference did commit through a path that bypassed the guard, the compensating delete removes the child and, where the relationship was two-sided, restores the parent from a tombstone:

# Compensating rollback for a reference that escaped the guard.
db.orders.delete_one({"_id": bad_order_id})   # remove the orphaned child
# time-to-recover: immediate on the primary; propagates at replication lag.

Because the abort path writes nothing, time-to-recover for an in-flight failure is zero. For a committed orphan, recovery is one compensating delete_one plus the replication lag to your slowest secondary. Treating each order’s linkage as reversible this way keeps a broken reference a remediation task rather than silent corruption; documents that cannot be re-linked belong in fallback routing for invalid documents.

Frequently Asked Questions

Can a $jsonSchema validator ever check that customer_id exists in another collection?

No. A validator receives only the single document being written and has no query engine behind it, so it cannot run a $lookup or a find against customers. It can require customer_id and pin its bsonType to objectId, which guarantees shape but never existence. Existence is a cross-document fact and must be enforced by a guarded transactional write or detected afterward by a scan.

Why wrap the existence check and the insert in one transaction instead of a plain read then write?

A read-then-write leaves a race: a concurrent delete of the customer between your find_one and your insert_one strands the order with no parent. A transaction reads the parent under snapshot isolation and commits the child atomically, so a conflicting concurrent delete surfaces as a WriteConflict and forces a retry rather than a silent orphan. The read and the write become one indivisible fact.

If the write-time guard is correct, why still run an orphan scan?

The guard only protects writes that pass through it. Documents inserted before the guard existed, references broken by an out-of-band migration or a direct mongosh session, and parents removed through an unguarded delete all produce orphans the write path never saw. The scheduled lookup anti-join is a lagging safety net that converts those into a measured, quarantinable backlog.