Limiting Document Size and Nesting Depth in Validators
A document with no upper bounds on its strings, arrays, or object keys is a resource-exhaustion vector — one oversized write can bloat indexes, blow memory during aggregation, and drag the working set out of cache — and the operational question this page answers is how to cap those dimensions in a $jsonSchema validator, sitting under security boundaries in schema design within the broader MongoDB JSON Schema Validation Architecture. The reader outcome is a bounded contract: maxLength on strings, maxItems on arrays, maxProperties on objects, and explicit bounded nesting, backed by MongoDB’s hard 16 MB BSON document ceiling, so an unbounded payload is rejected at the write path with WriteError code 121 long before it degrades the deployment.
The threat is not always malicious. A retry loop that appends to an array without pruning, a webhook that echoes an entire upstream payload into a field, or an attacker deliberately padding a request all produce the same failure: a document that grows without limit. Bounding each dimension turns “grows forever” into a predictable, rejected write.
Operational Mechanics and Write-Path Impact
Each bounding keyword constrains one dimension of a document and is evaluated on every insert, update, replace, and findAndModify. maxLength caps the character length of a string; maxItems caps the element count of an array; maxProperties caps the number of keys in an object. None of them re-scan existing documents — like every validator change, they apply only to future writes under the active validationLevel. The backstop beneath all of them is the 16 MB BSON document limit, a hard server constraint that rejects any document exceeding it regardless of the validator; your explicit bounds exist to fail earlier, with a precise reason, and at a threshold you choose rather than at the last possible megabyte.
The reason to bound well below 16 MB is that the damage begins long before the ceiling. An unbounded array is the worst offender: every element is a value the query planner may have to scan, a multikey index entry it may have to maintain, and a chunk of BSON that must be read into memory to return the document. A single field that holds an ever-growing array can bloat a multikey index by orders of magnitude and evict hot data from the WiredTiger cache. Unbounded strings and unbounded key counts have the same character — they trade a small per-write check for protection against a large, deferred operational cost.
| Keyword | Bounds | Resource risk if unbounded | Sensible starting cap |
|---|---|---|---|
maxLength |
String character count | Oversized values bloat storage and slow index reads | 256–4096 for identifiers and labels |
maxItems |
Array element count | Multikey index bloat, memory pressure, slow scans | 100–1000 depending on access pattern |
maxProperties |
Object key count | Unbounded dynamic keys defeat indexing and planning | Just above the expected key count |
| (none) | Whole document | 16 MB BSON ceiling — a hard limit, opaque failure | Bound each dimension well below it |
A closed field set complements these bounds: maxProperties caps how many keys an object may hold, while additionalProperties: false caps which keys — pairing the two, as described under preventing field injection with additionalProperties: false, closes both the count and the identity of fields. The important limitation to design around is that $jsonSchema uses JSON Schema Draft 4 semantics, which has no $ref and no recursion. You cannot express “an object that may nest itself to arbitrary depth” and then cap that depth with a single rule. Depth is instead bounded structurally, by how many levels of properties you explicitly write — the schema stops nesting where you stop nesting, and anything deeper is rejected by the additionalProperties: false at the deepest declared level.
Exact Diagnostic Fingerprints and Fast Resolution
A bound violation on MongoDB 5.0+ names the specific keyword — maxItems, maxLength, or maxProperties — in schemaRulesNotSatisfied, reporting the limit and, for arrays and strings, the offending size. That fingerprint tells you which dimension blew out and therefore which producer to fix:
// Reproduce the fingerprint: an oversized array is rejected.
db.activity_events.insertOne({
actor_id: "u-42",
tags: Array.from({ length: 5000 }, (_, i) => "t" + i) // exceeds maxItems: 50
})
/* Expected: WriteError 121 with errInfo.details.schemaRulesNotSatisfied naming:
{ operatorName: "maxItems",
specifiedAs: { maxItems: 50 },
consideredValue: [ ... ], numberOfItems: 5000 } */
The 16 MB ceiling produces a different, blunter signature that you should recognize as the backstop firing when an explicit bound was missing:
| Signature | Where it appears | Root cause | Resolution |
|---|---|---|---|
operatorName: "maxItems", numberOfItems far above the cap |
errInfo.details.schemaRulesNotSatisfied |
An append-without-prune loop or echoed upstream list | Cap the array at the producer; page if growth is monotonic |
operatorName: "maxLength" on a text field |
Same array | A field absorbed an entire payload or an attacker padded it | Truncate or reject at the producer; keep the bound |
operatorName: "maxProperties" on an object |
Same array | Dynamic keys accumulating without limit | Move dynamic keys into a bounded array of key/value pairs |
BSONObjectTooLarge / code 10334, or code 17419 |
Driver / server, no validator keyword named | Document exceeded 16 MB — the hard ceiling, no explicit bound caught it first | Add maxLength/maxItems bounds so the failure is precise and early |
To find documents already carrying oversized dimensions, use $nor with the bounded schema, since $jsonSchema is a query operator; combine it with an aggregation size probe to rank the worst offenders:
// Count documents whose tags array already exceeds the intended cap.
db.activity_events.countDocuments({ $nor: [ { $jsonSchema: {
bsonType: "object",
properties: { tags: { bsonType: "array", maxItems: 50 } }
} } ] })
// Expected output: 0 on a bounded collection.
// Rank the largest documents so you know the backlog before enforcing.
db.activity_events.aggregate([
{ $project: { sizeBytes: { $bsonSize: "$$ROOT" }, tagCount: { $size: { $ifNull: ["$tags", []] } } } },
{ $sort: { sizeBytes: -1 } },
{ $limit: 5 }
])
Step-by-Step Playbook
The safe path measures current sizes, applies bounds in observe mode, remediates oversized documents, then enforces. Documents that cannot be trimmed in place should be diverted through fallback routing for invalid documents rather than silently truncated.
1. Measure current sizes. Before bounding anything, learn the real distribution with $bsonSize and array-length probes so your caps sit above legitimate data and below the danger zone:
db.activity_events.aggregate([
{ $group: {
_id: null,
maxBytes: { $max: { $bsonSize: "$$ROOT" } },
maxTags: { $max: { $size: { $ifNull: ["$tags", []] } } }
} }
])
// Expected output: e.g. { maxBytes: 48213, maxTags: 37 } — set caps just above these.
2. Apply bounds in observe mode. Attach the bounded schema with validationAction: "warn" so oversized writes are logged, not rejected. Note the explicit bounded nesting — metadata is closed and cannot recurse indefinitely:
db.runCommand({
collMod: "activity_events",
validator: { $jsonSchema: {
bsonType: "object",
additionalProperties: false,
required: ["actor_id", "tags"],
maxProperties: 12,
properties: {
actor_id: { bsonType: "string", maxLength: 64 },
tags: { bsonType: "array", maxItems: 50, items: { bsonType: "string", maxLength: 40 } },
note: { bsonType: "string", maxLength: 2048 },
metadata: {
bsonType: "object",
additionalProperties: false, // deepest declared level — no deeper nesting allowed
maxProperties: 8,
properties: {
source: { bsonType: "string", maxLength: 128 },
weight: { bsonType: "int", minimum: 0, maximum: 1000 }
}
}
}
}},
validationLevel: "strict",
validationAction: "warn"
})
// Expected output: { ok: 1 }
3. Remediate oversized documents. Trim arrays and truncate strings that exceed the caps, using the $nor audit to find them, until the backlog reaches zero:
db.activity_events.countDocuments({ $nor: [ { $jsonSchema: /* the bounded schema */ {} } ] })
// Must reach 0 before enforcing.
4. Promote to enforcement. Flip validationAction to error; the change is atomic and metadata-only:
db.runCommand({ collMod: "activity_events", validationAction: "error" })
// Expected output: { ok: 1 }
Failure Modes & Rollback
Each stage has a distinct failure mode and a bounded recovery:
- Legitimate large document rejected after step 4. A cap was set too tight relative to real data — the step 1 measurement was skipped or a legitimate use case grows past it. Widen the specific bound as a versioned schema change through schema versioning strategies for NoSQL; do not remove the bound wholesale.
- Monotonic array growth (step 3 count won’t reach zero). If
tagsgrows on every write with no pruning, remediation never converges because producers keep re-inflating documents. Fix the producer’s append logic first, then remediate — capping the schema without fixing the writer just moves the failure to the write path. - 16 MB ceiling hit before your bound. If you see
BSONObjectTooLargerather than a named keyword, an unbounded dimension slipped through — usually a nested object left open somaxPropertiesnever applied. Add the missing bound and re-audit.
Rollback is a single metadata-only collMod. The soft rollback reopens writes while keeping the bounds attached for telemetry; the hard rollback detaches the validator:
// Soft rollback: stop rejecting, keep the bounds for logging.
db.runCommand({ collMod: "activity_events", validationAction: "warn" })
// Hard rollback: detach the validator entirely (last resort).
db.runCommand({ collMod: "activity_events", validator: {}, validationLevel: "off" })
Both take effect on the primary at once and propagate through the oplog, so time-to-recover is the replication lag of your slowest secondary — typically seconds. Prefer the soft rollback; a hard rollback removes every dimension cap and re-exposes the resource-exhaustion vectors the bounds were closing.
Frequently Asked Questions
If MongoDB already enforces a 16 MB document limit, why add maxLength and maxItems?
The 16 MB BSON limit is a hard backstop that fires only at the very end, with an opaque BSONObjectTooLarge error and after a document has already grown pathologically large. Explicit bounds fail far earlier, at a threshold you choose, and name the exact dimension that blew out — array, string, or key count — so you can fix the responsible producer. They also protect against costs that appear well below 16 MB, such as multikey index bloat from an oversized array.
How do I limit nesting depth when $jsonSchema has no recursion?
MongoDB's $jsonSchema uses JSON Schema Draft 4, which has no $ref and no recursion, so you cannot write a self-referential depth rule. Depth is bounded structurally instead: you write out properties for each level you allow, and set additionalProperties false at the deepest declared level. Any object nested deeper than you declared is rejected, which caps depth precisely without a recursive construct.
Why is an unbounded array worse than an unbounded scalar field?
An array multiplies cost across every element. If the field is indexed, each element becomes a multikey index entry, so an ever-growing array bloats the index out of proportion to the data and evicts hot pages from cache. Reads must also pull every element into memory to return the document. A single unbounded scalar hurts storage; an unbounded array degrades indexing, planning, and the working set at once, which is why maxItems is usually the first cap to set.
Related
- Security Boundaries in Schema Design — the parent guide to validators as a persistence-layer security control.
- MongoDB JSON Schema Validation Architecture — the overarching write-path enforcement architecture these bounds plug into.
- Preventing Field Injection with additionalProperties: false — pairing
maxProperties(how many keys) with an allowlist (which keys). - Allowlisting Field Values with $jsonSchema enum — closing the values a bounded field may hold.
- Schema Versioning Strategies for NoSQL — widening a bound safely when legitimate data outgrows it.
For the authoritative behavior of the bounding keywords and the BSON size limit, consult the official MongoDB schema-validation documentation.