Filter concepts by levelShowing all levels.

MongoDB · Section 9

Schema Validation

Level
intermediate
Read
30 min
Concepts
6

How MongoDB's collection validators add structural and type guarantees on top of an otherwise schema-flexible collection: the $jsonSchema vocabulary, deciding which fields are worth validating, validationLevel and validationAction, balancing validation strength against a collection's role, and why a database-level validator is a necessary backstop even when the application already validates its own input.

This section

What is true here

  1. A collection validator only constrains the fields it explicitly names — everything else stays flexible.
  2. $jsonSchema describes required fields and per-field constraints (bsonType, minimum/maximum, pattern, enum), nesting for embedded documents and arrays.
  3. Validate the fields other code actually depends on having a specific shape, not every field uniformly.
  4. validationLevel controls which writes are checked; validationAction controls what happens when one fails.
  5. Application-side validation only covers the code paths that use it — a collection validator covers every write path, including ones added later.

What you will be able to do

  • Attach and modify a collection validator with createCollection and collMod
  • Write a $jsonSchema covering required fields, type/range constraints, and nested documents
  • Decide which fields in a given collection are worth validating
  • Choose validationLevel and validationAction appropriately for a rollout versus a mature, stable collection
  • Explain why a collection validator is still needed even when the application already validates input

Writing a validator

What a collection validator is, the $jsonSchema vocabulary that expresses it, and choosing which fields are worth constraining.

Collection validators

coreintermediate

A collection validator is a set of rules, attached to a collection, that MongoDB checks against every insert and (depending on settings) every update. It is the database enforcing structure on an otherwise schema-flexible collection, not a replacement for it.

Think of it as

A collection without a validator accepts any document shape — flexibility with no safety net. A validator adds the safety net without removing the flexibility everywhere else: it can require some fields and constrain some types while leaving anything not mentioned free to vary, unlike a relational table's fixed column list.

text
db.createCollection(<name>, { validator: { $jsonSchema: {...} }, validationLevel: <level>, validationAction: <action> })

What we're doing: Show a validator rejecting an insert that violates it, and one that is allowed because the field it violates is not covered.

validator-partial-coverage.txttext
// Rejected: "total" is required and must be a non-negative decimal
db.orders.insertOne({ customerId: 1, total: -5 })
// -> Document failed validation

// Allowed: "notes" is not mentioned by the validator, so any shape is fine
db.orders.insertOne({ customerId: 1, total: 20.00, notes: { anything: "goes" } })
2
The validator enforces total >= 0 because that field is explicitly constrained — the insert is rejected before it is written.
6
The "notes" field is not mentioned anywhere in the validator, so it passes through unconstrained — this is what "partial" validation means in practice.

Why this works: A validator lets a team lock down the fields that matter for correctness (an order must have a non-negative total) while leaving room for fields that genuinely vary or are still evolving — the two are not mutually exclusive in the same collection.

Assuming a validator with a few required fields makes the whole document schema-safe

Wrong

text
// A validator requiring only { customerId, total }, then trusting any other field is also well-formed

Better

text
// Explicitly list every field that must be structurally correct in the $jsonSchema — anything omitted really is unconstrained

What you see: A malformed value lands in a field the team assumed was covered, because the validator never actually mentioned it.

Why: A collection validator only checks what it explicitly lists — omission is not an oversight the database catches for you, it is the schema-flexible default applying to anything the validator does not name.

A validator is a partial rule set, not a fixed schema

orders collection

customerId, total

required, type-checked

everything else

still schema-flexible

  • orders collection
    • customerId, total — required, type-checked
    • everything else — still schema-flexible

Attaching a validator

Attaching a validator
WhenHow
New collectiondb.createCollection(name, { validator: {...} })
Existing collectiondb.runCommand({ collMod: name, validator: {...} })
Remove validationdb.runCommand({ collMod: name, validator: {}, validationLevel: "off" })

Together

text
db.createCollection("orders", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["customerId", "total"],
      properties: {
        total: { bsonType: "decimal", minimum: 0 }
      }
    }
  }
})

Remember: A collection validator enforces structure only on the fields it explicitly lists — attach with createCollection or collMod, and check existing data before switching to strict.

See also: json schema validation · schema flexible not free

JSON Schema validation concepts

standardintermediate

$jsonSchema is the standard, richest way to write a validator: required lists which fields must exist, properties describes each field's bsonType and constraints (minimum, maximum, pattern, enum), and schemas can nest for embedded documents and arrays.

Think of it as

$jsonSchema reads like a contract for a document's shape: "these fields must be present, and if a field is present it must look like this." It is declarative — you describe the valid shape, not the steps to check it — the same way a TypeScript interface describes a shape rather than a validation function.

text
{ bsonType: "object", required: [...], properties: { <field>: { bsonType: ..., ... } } }

What we're doing: Write a $jsonSchema covering a required field, a range constraint, an enum, and a nested embedded document.

jsonschema-shape.txttext
{
  bsonType: "object",
  required: ["status", "total"],
  properties: {
    status: { enum: ["pending", "shipped", "delivered", "cancelled"] },
    total: { bsonType: "decimal", minimum: 0 },
    shipping: {
      bsonType: "object",
      properties: { city: { bsonType: "string" } }
    }
  }
}
4
enum restricts "status" to exactly these four values — an insert with any other string is rejected.
9
A nested properties schema validates the shape of the embedded "shipping" document, the same way the top level does.

Why this works: The same schema vocabulary (bsonType, required, properties) applies at any nesting level, so an embedded document's shape gets the same rigor as the top-level document without a different syntax to learn.

Using JSON Schema's type instead of MongoDB's bsonType

Wrong

text
{ properties: { total: { type: "number" } } }  // standard JSON Schema keyword

Better

text
{ properties: { total: { bsonType: "decimal" } } }  // MongoDB's BSON-aware keyword

What you see: A validator written with type instead of bsonType either fails to distinguish BSON types MongoDB actually stores (int32 vs. double vs. decimal128 all read as "number" in plain JSON Schema) or is silently ignored depending on the field.

Why: $jsonSchema is MongoDB's BSON-aware implementation of the JSON Schema standard — bsonType is the extension that lets a validator distinguish MongoDB's richer type set, which plain JSON Schema's type keyword has no vocabulary for.

Remember: $jsonSchema: required lists mandatory fields, properties constrains each one by bsonType (not type) — nests the same way for embedded documents and arrays.

See also: collection validators · bson types

Using validation for structural and type guarantees

standardintermediate

Reach for a validator on the fields where a wrong type or a missing value would break something downstream — a total that must be a number, a status that must be one of a fixed set, an id that must reference a real type. Not every field needs this level of guarantee.

Think of it as

Think of validation rules as a short list of "if this is wrong, something breaks" invariants, not an attempt to fully describe the document. The fields worth validating are the ones a query, a report, or a downstream service actually depends on being a specific shape — everything else can stay flexible without cost.

text
// Ask per field: "if this is missing or the wrong type, what breaks downstream?" — validate exactly those

What we're doing: Distinguish a field worth validating (breaks a report if wrong) from one not worth validating (nothing depends on its shape).

worth-validating-or-not.txttext
// Worth validating: a revenue report sums "total" — a string here breaks the $sum aggregation
{ total: { bsonType: "decimal", minimum: 0 } }  // required, type-checked

// Not worth validating: "notes" is only ever displayed as free text, nothing depends on its shape
// (left out of the validator entirely — schema-flexible by default)
2
A revenue report's $sum aggregation on "total" produces wrong or erroring results if even one document stores it as a string — this is exactly the kind of downstream break validation prevents.
5
Nothing downstream cares what shape "notes" takes, so validating it would add friction (rejecting legitimate free-text variety) without preventing any real failure.

Why this works: The decision of what to validate is itself a design choice, guided by what actually depends on a field's shape — not a blanket "validate everything" or "validate nothing" default.

Validating every field uniformly regardless of whether anything depends on its shape

Wrong

text
// A $jsonSchema listing bsonType for every single field in the document, including free-form/display-only ones

Better

text
// Validate the fields queries, aggregations, and other services depend on; leave display-only or genuinely variable fields unconstrained

What you see: Legitimate writes get rejected for shape variations in fields nothing actually depended on being uniform, and the team starts working around the validator instead of trusting it.

Why: Validation that is stricter than what the application actually needs trains people to route around it (loosen it, or stop using createCollection's validator at all) — a validator that only covers real invariants stays trusted and worth keeping strict.

Remember: Validate the fields other code actually depends on having a specific shape — a wrong type there breaks something downstream. Leave genuinely variable or display-only fields unconstrained.

See also: collection validators · not only application side validation

Advertisement

Enforcement and scope

How strictly a validator is enforced, matching that strictness to a collection's role, and why it is a backstop beyond application-side checks.

validationLevel and validationAction

coreintermediate

validationLevel controls which writes get checked: strict (every insert and update) or moderate (inserts, plus updates only to documents already valid — existing invalid documents are left alone). validationAction controls what happens on failure: error (reject) or warn (allow, but log).

Think of it as

The two settings answer two independent questions: "which writes get checked" (level) and "what happens when a checked write fails" (action). Crossing them gives four real combinations — the two most useful being strict+error (full enforcement) for a stable schema, and moderate+warn for rolling out a new validator against a collection with pre-existing, not-yet-migrated data.

text
{ validationLevel: "strict" | "moderate" | "off", validationAction: "error" | "warn" }

What we're doing: Show moderate+warn as a safe rollout path on a collection with legacy documents, then tightening to strict+error once clean.

validator-rollout-path.txttext
// Step 1: roll out cautiously — see what would fail, without rejecting anything yet
db.runCommand({ collMod: "orders", validator: {...}, validationLevel: "moderate", validationAction: "warn" })
// -> check the logs for violations, fix or migrate the offending documents

// Step 2: once the logs are clean, enforce for real
db.runCommand({ collMod: "orders", validator: {...}, validationLevel: "strict", validationAction: "error" })
2
moderate+warn is the safest way to introduce a new validator: nothing is rejected yet, but the logs reveal exactly which existing documents or write paths would fail once enforcement tightens.
6
Switching to strict+error only after the warnings are addressed avoids a surprise wave of rejected writes on day one of enforcement.

Why this works: Deploying a brand-new validator directly as strict+error risks rejecting legitimate application writes the moment any existing document or write path does not yet conform — the warn action gives visibility before the error action starts blocking anything.

Deploying a new validator as strict+error on a collection with unknown existing data quality

Wrong

text
// db.runCommand({ collMod: "orders", validator: {...}, validationLevel: "strict", validationAction: "error" })  — as the very first deployment

Better

text
// Start with validationAction: "warn" (or validationLevel: "moderate") to observe real violations before enforcing them

What you see: Application writes that used to succeed start failing immediately after the validator deploys, with no advance warning of which documents or code paths were affected.

Why: strict+error checks and rejects everything from the moment it is set — without a warn or moderate rollout step first, there is no visibility into how much existing data or how many write paths would be affected before enforcement actually starts blocking them.

Level × action — four real combinations
moderate + warn
gradual rollout on legacy data
moderate + error
enforce new rules, spare legacy docs
strict + warn
test a new rule's real impact
strict + error
full enforcement, stable schema
  • moderate + warn: action: warn, level: moderate — gradual rollout on legacy data
  • moderate + error: action: error, level: moderate — enforce new rules, spare legacy docs
  • strict + warn: action: warn, level: strict — test a new rule's real impact
  • strict + error: action: error, level: strict — full enforcement, stable schema

Level × action, and when each combination fits

Level × action, and when each combination fits
LevelActionFits
stricterrora stable schema with no legacy non-conforming documents
strictwarntesting a new rule's real-world impact before enforcing it
moderateerrorenforcing new rules going forward, without breaking updates to legacy documents
moderatewarnrolling out a validator gradually on a collection with a lot of legacy data

Together

text
db.runCommand({
  collMod: "orders",
  validator: { $jsonSchema: {...} },
  validationLevel: "moderate",
  validationAction: "warn"
})

Remember: validationLevel picks which writes get checked (strict = all, moderate = spares already-invalid documents); validationAction picks what happens on failure (error = reject, warn = log only). Roll out new validators as moderate/warn first.

See also: collection validators · balancing flexibility and validation

Balancing flexible evolution with strong validation

standardintermediate

Not every collection needs the same validation strength. Business-critical data (payments, orders, inventory) benefits from strict validation; fast-evolving or exploratory data (feature flags, event payloads, logs) benefits more from staying flexible while the shape is still settling.

Think of it as

Validation strength is a dial per collection, not a single project-wide setting. A collection's position on that dial should track how costly a bad document would be versus how much its shape is still expected to change — critical, stable data leans toward strict validation; fast-moving, exploratory data leans toward staying loose.

text
// Per collection: how costly is a bad document here, versus how much is the shape still expected to change?

What we're doing: Contrast validation strength across two collections in the same application, and show a collection moving from loose to strict as it matures.

per-collection-validation-strength.txttext
// Payments: business-critical, stable shape — strict validation from day one
db.createCollection("payments", { validator: { $jsonSchema: {...} }, validationLevel: "strict" })

// A new "featureEvents" collection, shape still being iterated on — no validator yet
db.createCollection("featureEvents")
// -> revisit once the event shape has stabilized across a few releases, then add a validator
2
A malformed payment document is expensive — strict validation from the start is worth the friction it adds.
6
An event schema still being designed benefits more from staying flexible while the team learns what fields it actually needs, than from locking in rules that will need frequent changing.

Why this works: The same engineering team reasonably makes opposite validation-strength decisions for two collections in the same project, because the two questions that decide it — cost of a bad document, and how settled the shape is — have different answers for each.

Applying one project-wide validation policy to every collection regardless of its role

Wrong

text
// "All collections get a strict $jsonSchema validator" as a blanket team standard, applied on day one to a collection whose shape is still being designed

Better

text
// Set validation strength per collection, based on that collection's actual cost-of-bad-data and shape stability

What you see: A collection still being iterated on requires a validator change on every schema tweak, slowing down legitimate development for a collection where a temporarily malformed document was never actually costly.

Why: A blanket policy optimizes for consistency across collections that do not actually share the same risk profile — a payments collection and an in-development event collection have different costs of getting it wrong, and the validation strategy should reflect that difference rather than ignore it.

Remember: Set validation strength per collection: strict for critical, stable data (payments, orders); loose for fast-evolving, exploratory data — and tighten it once a collection's shape settles.

See also: validation levels and actions · schema flexible not free

Application validation is not the only protection

coreintermediate

Application code that validates before writing is easy to bypass: a second service, a one-off script, a bulk import, or a bug in that same code path can all write directly to the collection without going through the validation logic. A collection validator checks every write, regardless of where it came from.

Think of it as

Application-side validation is a gate on one door into the house; a collection validator is a check at every door, including ones added later. Any write path that reaches the database directly — a migration script, an admin tool, a second microservice, a REPL session — walks past application validation entirely, but not past a database-level validator.

text
// App-side validation: fast feedback, one code path. Database validator: no bypass, every code path.

What we're doing: Show a bad document reaching a collection through a path that skips application validation, and a validator catching what the app layer missed.

bypassed-app-validation.txttext
// A one-off migration script writes directly, skipping the app's normal order-creation code entirely:
db.orders.insertOne({ customerId: 1, total: "twenty" })   // wrong type, app validation never ran

// With a collection validator in place, the same write is rejected regardless of its source:
db.orders.insertOne({ customerId: 1, total: "twenty" })
// -> Document failed validation: total must be decimal
2
A migration script talking to the database directly has no reason to reuse the application's validation logic — and often does not.
6
The collection validator applies to this insert exactly as it would to any other, because it runs at the database rather than in any one code path.

Why this works: The number of code paths that can reach a collection tends to grow over a project's life — new services, scripts, admin tools — and each one that was not built with the original application's validation in mind is a gap a database-level validator closes automatically.

Relying entirely on the application's input validation and skipping a collection validator

Wrong

text
// "Our API already validates every request body before writing" — as the sole protection against bad data in the collection

Better

text
// Keep the app-side validation for fast, friendly error messages, and add a collection validator as the backstop that covers every write path, including ones that do not exist yet

What you see: A data-quality issue traces back to a write that never went through the API — a migration, a second service, a manual fix — none of which the application's validation logic ever saw.

Why: Application validation protects exactly one door into the collection; every other current or future write path bypasses it entirely, which is precisely the gap a database-level validator is designed to close regardless of which path a given write came through.

Every write path, one shared backstop

App API writes

validated by application code

Migration scripts

bypass the app entirely

Other services

their own write paths, own bugs

Collection validator

runs at the database — every path above hits it

  1. App API writes — validated by application code
  2. Migration scripts — bypass the app entirely
  3. Other services — their own write paths, own bugs
  4. Collection validator — runs at the database — every path above hits it

Remember: Application validation only covers the code paths that use it — a collection validator runs at the database and catches every write path, including ones added later. Use both, not one instead of the other.

See also: collection validators

Advertisement