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.
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.
- Define the array schema with an explicit element contract. Use
anyOfinsideitemsfor polymorphic elements and close each nested object withadditionalProperties: 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 } ] } } } }; - Deploy in
warnto collect telemetry without blocking writes. Attach the validator withcollMod; non-compliant array writes are logged (id51803) rather than rejected:
Expected output:db.runCommand({ collMod: "events", validator: { $jsonSchema: arraySchema }, validationLevel: "moderate", validationAction: "warn" });{ ok: 1 }. - 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.
Related
- Understanding MongoDB
$jsonSchemaSyntax — the parent syntax and deployment reference this array guide sits under. - JSON Schema Draft 4 vs Draft 2019 in MongoDB — porting
unevaluatedItemsand tuple keywords to the Draft 4itemsthat MongoDB honors. - Strict vs Moderate Validation Levels — deciding which documents get their arrays checked during a rollout.
- Fallback routing for invalid documents — where quarantined array elements go and how they are reconciled.
- Setting up validationAction warn vs error in production — the phased
warn→errorpromotion this playbook depends on.