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.

Bounded dimensions fail early, before the 16 MB BSON ceiling A write passes through three explicit dimension gates in sequence: maxLength on strings, maxItems on arrays, and maxProperties on objects. A document within all three bounds proceeds to the storage engine. A document that violates any bound is rejected early with code 121 naming the exact keyword. Behind the explicit gates sits the hard 16 MB BSON limit, drawn as a final backstop that rejects anything that somehow reached it with an opaque BSONObjectTooLarge error, which the explicit bounds exist to pre-empt. Client write insert / update maxLength strings maxItems arrays maxProperties objects in bounds Storage engine persisted 16 MB BSON backstop over a bound Reject write code 121 · exact keyword

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 tags grows 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 BSONObjectTooLarge rather than a named keyword, an unbounded dimension slipped through — usually a nested object left open so maxProperties never 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.

For the authoritative behavior of the bounding keywords and the BSON size limit, consult the official MongoDB schema-validation documentation.