Validating Nested Arrays with $jsonSchema: items, minItems, and Polymorphic Elements

Nested arrays are the single most fragile surface in a MongoDB $jsonSchema validator, because the items keyword enforces one schema across every element and quietly rejects the heterogeneous shapes that real ingestion produces. This page is a precise operational playbook — part of Understanding MongoDB $jsonSchema Syntax within the broader MongoDB JSON Schema Validation Architecture — that shows exactly how array constraints are evaluated on the write path, the error fingerprints they emit, and a runnable sequence for deploying array validation without stalling a high-throughput pipeline. The deliverable is a validator you can express correctly the first time plus a PyMongo routing loop that quarantines failing array elements instead of aborting the batch.

Operational Mechanics and Write-Path Impact

MongoDB compiles a $jsonSchema into a query predicate that runs synchronously on every insert, update, and replace. For an array field, the engine evaluates the array-level keywords in a fixed order before it ever inspects element content: it checks bsonType: "array", then minItems/maxItems, then uniqueItems, and only then applies the items subschema to each element, short-circuiting on the first violation. That ordering is why an empty array under minItems: 1 fails before its element schema is consulted — the rejection looks like it ignored the items rules, when in fact it never reached them.

The items keyword is homogeneous: the schema you declare is applied identically to element 0 and element N. Heterogeneous arrays — an event log holding both click and purchase shapes, for example — must route each element through an anyOf or oneOf combinator inside the items block, or every element that does not satisfy the single declared shape is rejected. MongoDB implements Draft 4 semantics, so tuple validation via a positional items array is available but later-draft keywords such as unevaluatedItems are not; that boundary is mapped in full in JSON Schema Draft 4 vs Draft 2019 in MongoDB.

Which documents actually get checked is a separate axis governed by strict vs moderate validation levels: strict validates every insert and update, while moderate skips updates to documents that already violate the schema. The keyword reference every array validator relies on:

Keyword Evaluated Effect on the write path
bsonType: "array" First Rejects non-array values before any element check runs
minItems / maxItems Before items Bounds cardinality; an empty array fails minItems: 1 before element rules apply
uniqueItems Before items Enforces distinct elements; costly on large arrays under burst load
items (single schema) Per element Applies one schema to every element; short-circuits on first miss
items (array of schemas) Positional Draft 4 tuple validation; pair with additionalItems: false to close the tail
anyOf / oneOf inside items Per element The only correct way to validate polymorphic (heterogeneous) elements

A performance trap hides in this table: a high minItems threshold or uniqueItems: true on a frequently-updated large array forces the server to re-scan the entire array on every partial update, amplifying write latency. Treat a deep array validator as an active component of write-path cost, not free metadata.

The fixed order in which MongoDB evaluates array keywords in a $jsonSchema validator A write enters three array-level gates evaluated in a fixed order — bsonType array, then minItems/maxItems, then uniqueItems — and any one of them failing short-circuits immediately to WriteError 121 before element content is ever inspected. Only after all three pass does the items keyword run per element, where an anyOf or oneOf combinator routes each element to one of several allowed shapes (for example a click shape or a purchase shape); a matching element is accepted and an element that matches no shape is rejected as WriteError 121. Array keywords evaluate in a fixed order — array-level gates run before per-element items Array-level · short-circuits on first fail Client write insert · update · replace 1 bsonType: array reject non-arrays 2 minItems / maxItems cardinality bounds 3 uniqueItems distinct elements 4 items one schema / element fail · short-circuit WriteError 121 Document failed validation Per-element (items) — one subschema per element for each element for each element anyOf oneOf { type: 'click', ts: date } { type: 'purchase', amount } matches Accept write no shape matches

Exact Diagnostic Fingerprints and Fast Resolution

Array validation fails with two distinct signatures, and telling them apart is what makes triage fast. A document that violates the array rules raises WriteError code 121 (Document failed validation) at write time, with the failing path in errInfo.details.schemaRulesNotSatisfied naming the offending keyword (items, minItems, uniqueItems) and the array’s property path. A malformed validator — for example an unsupported later-draft array keyword — fails earlier with OperationFailure at collMod/createCollection time, before any document is written. Never conflate the two: the first is a data problem, the second is a schema problem.

In bulk operations the code-121 signal is nested. PyMongo raises BulkWriteError, and each failing element sits in bwe.details["writeErrors"] as an object carrying index (the position in the operation list), code, and errmsg. Extract that index to map the rejection back to the original payload rather than losing it:

from pymongo.errors import BulkWriteError, OperationFailure

try:
    result = collection.bulk_write(operations, ordered=False)
except BulkWriteError as bwe:
    for err in bwe.details.get("writeErrors", []):
        if err["code"] == 121:
            info = err.get("errInfo", {}).get("details", {})
            rules = info.get("schemaRulesNotSatisfied", [])
            print(err["index"], rules)  # array path + failing operator
except OperationFailure as exc:
    # schema-level failure at deploy time, not a document rejection
    print("validator rejected:", exc.details.get("errmsg", str(exc)))

To size the blast radius before enforcing anything, use $jsonSchema as a query operator and count how many stored documents the candidate array schema would reject — with no validator active:

// mongosh — arraySchema is your candidate $jsonSchema body
db.events.countDocuments({ $nor: [ { $jsonSchema: arraySchema } ] })

A non-zero count means a strict cutover would reject updates to those documents, so clean the data or roll out under moderate first. For authoritative operator semantics see the official MongoDB schema validation documentation.

Step-by-Step Playbook

This sequence deploys a nested-array validator on a live collection and routes failing elements to a quarantine collection instead of aborting ingestion. Each step is runnable and reversible.

Quarantine-routed bulk insert that isolates array-validation failures instead of aborting the batch An unordered bulk_write validates each element against the items and anyOf schema. Compliant documents branch to insert document and persist. Non-compliant elements branch to a collection step that reads them from BulkWriteError.writeErrors, are routed by their operation index to a validator-free quarantine collection, and are then reconciled and re-inserted — so a failing element diverts rather than stalling the whole batch. Rejected elements divert to quarantine — the batch keeps flowing instead of aborting bulk_write ordered = false validate element items · anyOf element compliant? insert document persist yes collect from writeErrors[index] no route to quarantine reconcile & re-insert quarantine has no validator — it can never re-reject the payload
  1. Define the array schema with an explicit element contract. Use anyOf inside items for polymorphic elements and close each nested object with additionalProperties: false:
    const arraySchema = {
      bsonType: "object",
      required: ["events"],
      properties: {
        events: {
          bsonType: "array",
          minItems: 1,
          items: {
            anyOf: [
              { bsonType: "object", required: ["type", "ts"],
                properties: { type: { enum: ["click"] }, ts: { bsonType: "date" } },
                additionalProperties: false },
              { bsonType: "object", required: ["type", "amount"],
                properties: { type: { enum: ["purchase"] }, amount: { bsonType: "decimal" } },
                additionalProperties: false }
            ]
          }
        }
      }
    };
  2. Deploy in warn to collect telemetry without blocking writes. Attach the validator with collMod; non-compliant array writes are logged (id 51803) rather than rejected:
    db.runCommand({ collMod: "events", validator: { $jsonSchema: arraySchema },
                    validationLevel: "moderate", validationAction: "warn" });
    Expected output: { ok: 1 }.
  3. Bulk-insert with client-side pre-flight and quarantine routing. Run the loop below; it pre-validates array shapes, inserts the compliant ones unordered so failures isolate, and diverts rejected elements. Once the warning rate settles, promote to enforcement with db.runCommand({ collMod: "events", validationAction: "error" }).
from pymongo import InsertOne
from pymongo.errors import BulkWriteError
from jsonschema import Draft4Validator
from typing import Any, Dict, List


def bulk_insert_with_array_validation(
    collection,
    documents: List[Dict[str, Any]],
    array_schema: Dict[str, Any],
    quarantine_collection,
) -> Dict[str, int]:
    """Pre-validate array fields client-side, bulk-insert the compliant docs,
    and route both pre-flight and server-side failures to a quarantine
    collection. Returns counts of inserted, pre-flight, and quarantined docs."""
    validator = Draft4Validator(array_schema)
    valid_docs, pre_flight = [], []

    for doc in documents:
        errors = [e.message for e in validator.iter_errors(doc)]
        (valid_docs if not errors else pre_flight).append(
            doc if not errors else {"document": doc, "errors": errors}
        )

    inserted = 0
    if valid_docs:
        try:
            inserted = collection.bulk_write(
                [InsertOne(d) for d in valid_docs], ordered=False
            ).inserted_count
        except BulkWriteError as bwe:
            failed = {e["index"] for e in bwe.details.get("writeErrors", [])}
            inserted = bwe.details.get("nInserted", 0)
            for idx in failed:
                quarantine_collection.insert_one(
                    {"original_document": valid_docs[idx],
                     "stage": "db_level", "error": "WriteError 121"}
                )

    for failure in pre_flight:
        quarantine_collection.insert_one(
            {"original_document": failure["document"],
             "stage": "pre_flight", "errors": failure["errors"]}
        )

    return {"inserted": inserted,
            "pre_flight": len(pre_flight),
            "quarantined": len(pre_flight) + (len(valid_docs) - inserted)}

The quarantine collection must be created without a validator so it can never recursively reject the payloads you are trying to preserve; feeding those payloads back through a repair job is the job of fallback routing for invalid documents and the wider building fallback validation chains pattern. Client-side pre-flight uses the Draft4Validator from the jsonschema library so the client draft matches MongoDB’s server draft exactly.

Failure Modes & Rollback

Failure Symptom Recovery
Polymorphic array under single items Every non-matching element throws WriteError 121 Wrap element schemas in anyOf/oneOf inside items; re-apply with collMod
minItems masks element rules Empty-array docs fail before items runs Confirm the path names minItems, not items, in schemaRulesNotSatisfied
strict cutover on dirty data Update-time 121 rejection spike Soft-rollback to warn; re-run the $nor count and clean before promoting
Validator rejected at deploy OperationFailure at collMod Later-draft array keyword used; port it to Draft 4 and retry on a scratch namespace

The soft rollback keeps the validator attached for telemetry but stops rejecting writes; the hard rollback detaches it entirely. Both are metadata-only, take effect immediately on the primary, and propagate through the oplog — so real time-to-recover is the replication lag of your slowest secondary, typically seconds:

// Soft rollback — restore write availability, keep the contract observable.
db.runCommand({ collMod: "events", validationAction: "warn" });

// Hard rollback — remove the validator completely.
db.runCommand({ collMod: "events", validator: {}, validationLevel: "off" });

Pre-stage the soft-rollback command before every error cutover, and after a high-volume rejection cycle run db.events.validate() to confirm index and BSON integrity before closing the incident. Wiring the client and server array definitions to one versioned source is covered by schema versioning strategies for NoSQL; the deployment machinery itself lives in implementing collection-level validators.

Frequently Asked Questions

How do I validate an array whose elements have different shapes?

Put an anyOf or oneOf combinator inside the items block, with one subschema per shape. The bare items keyword is homogeneous — it applies a single schema to every element — so a mixed-shape array under a single items schema rejects every element that does not match it. Use oneOf when the shapes are mutually exclusive and anyOf when overlap is allowed.

Why does an empty array fail even though my items schema allows anything?

Because MongoDB evaluates minItems and maxItems before it applies items to elements. An empty array under minItems: 1 is rejected at the cardinality gate, and the failing path in errInfo.details.schemaRulesNotSatisfied names minItems, not items. Read that path to distinguish a cardinality rejection from an element-content rejection.

Does additionalProperties: false close nested objects inside array elements?

No. It closes only the object level where it is declared. Each object nested inside an array element must repeat additionalProperties: false for its own level, or that level stays open to unexpected keys — a common source of silent array drift that passes validation while carrying junk fields.