Schema Validation with $jsonSchema

MongoDB collections don’t require every document to share the same shape — normally that’s a feature, letting you evolve your data model without a table-wide migration. But most real applications still want guarantees: every user has an email, every order’s status is one of a known set of values, every price is a non-negative number. MongoDB’s $jsonSchema validator lets you declare and enforce exactly those rules directly on a collection, so bad data is rejected — or at least flagged — the moment it’s written, no matter which script, service, or person is doing the writing.

Overview / How it works

A MongoDB document is a BSON object, and by default a collection places no constraints on what shape those objects take — two documents in the same users collection can have completely different fields. This flexibility is genuinely useful (you can add a field to new documents with no ALTER TABLE-style downtime), but it also means nothing stops a buggy script from inserting a user with no email or a negative price. $jsonSchema closes that gap by attaching a validator to the collection itself.

A validator is a predicate stored in the collection’s metadata. You supply it either when you create the collection with db.createCollection(), or attach/replace it later on an existing collection with the collMod command. From that point on, every insertOne, insertMany, updateOne, updateMany, and findOneAndUpdate that would create or modify a document in that collection is checked against the schema before MongoDB commits the write. A document that fails validation is rejected with a MongoServerError (or merely logged, depending on configuration) — the write never reaches the storage engine.

$jsonSchema is based on a large subset of the JSON Schema Draft 4 specification, extended with a bsonType keyword so you can validate against BSON-specific types that plain JSON Schema has no concept of — objectId, date, decimal (Decimal128), long, timestamp, and more — in addition to ordinary types like string, object, array, bool, and number. Prefer bsonType over the plain JSON Schema type keyword whenever you can, for reasons covered in Common Mistakes below.

Two settings control how strict enforcement is. validationLevel is "strict" by default — every insert and every update is checked — or "moderate", where only documents that already satisfied the schema before the update are re-checked; documents that predate the validator, or were written before it existed, are left alone until you explicitly fix them. validationAction is "error" by default (reject the write) or "warn" (allow the write, but log a warning) — useful for rolling validation onto a live collection without breaking existing writers overnight.

Validation is purely a write-time integrity check; it is not an index and does not speed up queries — you still need real indexes for query performance, that’s a separate concern entirely. It runs on the primary as part of the write path; secondaries never re-validate, they simply replicate the oplog entry the primary already accepted.

Syntax

db.createCollection('<collection>', {
  validator: {
    $jsonSchema: {
      bsonType: 'object',
      required: ['<field1>', '<field2>'],
      properties: {
        '<field1>': { bsonType: '<type>', description: '<shown on validation failure>' }
      }
    }
  },
  validationLevel: 'strict',
  validationAction: 'error'
});
  • validator — the predicate object; for schema validation it is always { $jsonSchema: {...} }.
  • bsonType — required at the top level, almost always 'object' since a document is an object; also used per-field.
  • required — an array of field names that must be present on the document.
  • properties — an object mapping each field name to its own schema (type, constraints, nested properties). Fields not listed here are unconstrained unless additionalProperties: false is set.
  • validationLevel'strict' (default, validates all writes) or 'moderate' (grandfathers documents that already violated the schema).
  • validationAction'error' (default, rejects invalid writes) or 'warn' (allows the write, logs a warning).

The most common keywords you’ll use inside properties:

Keyword Applies to Meaning
bsonType any field Required BSON type — string, int, double, bool, date, objectId, array, object, etc.
enum any field Value must be one of a fixed list.
minimum / maximum numbers Inclusive numeric bounds.
minLength / maxLength strings Bounds on string length.
pattern strings A regular expression the string must match.
minItems / maxItems arrays Bounds on array length.
items arrays Schema every array element must satisfy.
additionalProperties objects Set to false to reject any field not listed in properties.

Examples

Example 1: Enforcing required fields and types on a new collection

Create a users collection that requires a name, a plausible email, and an age within a sane range:

db.createCollection('users', {
  validator: {
    $jsonSchema: {
      bsonType: 'object',
      required: ['name', 'email', 'age'],
      properties: {
        name: { bsonType: 'string', description: 'must be a string and is required' },
        email: { bsonType: 'string', pattern: '^.+@.+$', description: 'must contain an @ and is required' },
        age: { bsonType: 'int', minimum: 0, maximum: 130, description: 'must be an integer in [0, 130] and is required' }
      }
    }
  }
});

Output:

{ ok: 1 }

Now insert a document that satisfies the schema:

db.users.insertOne({ name: 'Priya Sharma', email: 'priya@example.com', age: 29 });

Output:

{
  acknowledged: true,
  insertedId: ObjectId('66b1f2a1c8e4a2f001d3b9aa')
}

And a document that violates two rules at once — a malformed email and an out-of-range age:

db.users.insertOne({ name: 'Bad User', email: 'not-an-email', age: 200 });

Output:

MongoServerError: Document failed validation
Additional information: {
  failingDocumentId: ObjectId('66b1f2c3c8e4a2f001d3b9ab'),
  details: {
    operatorName: '$jsonSchema',
    schemaRulesNotSatisfied: [
      { propertyName: 'email', description: 'must contain an @ and is required' },
      { propertyName: 'age', description: 'must be an integer in [0, 130] and is required' }
    ]
  }
}

MongoDB never wrote the second document. The schemaRulesNotSatisfied array in the error tells you precisely which fields failed and echoes back the description you wrote for each one — which is exactly why it’s worth writing a real description on every property instead of leaving it out.

Example 2: Rolling validation onto an existing collection safely

Adding strict validation to a collection that’s already live in production can break writers you didn’t anticipate. Attach it in warn mode first, on a copy of the rule set you eventually want to enforce:

db.runCommand({
  collMod: 'orders',
  validator: {
    $jsonSchema: {
      bsonType: 'object',
      required: ['orderId', 'status', 'total'],
      properties: {
        orderId: { bsonType: 'string' },
        status: { enum: ['pending', 'shipped', 'delivered', 'cancelled'] },
        total: { bsonType: 'double', minimum: 0 }
      }
    }
  },
  validationLevel: 'moderate',
  validationAction: 'warn'
});

Output:

{ ok: 1 }

With validationAction: 'warn', non-conforming writes still succeed but MongoDB logs a warning to the server log describing exactly what failed. Watch that log for a while, fix whatever is producing the violations, then run the same command again with validationAction: 'error' to start enforcing it for real.

Example 3: Validating a nested array of embedded documents

Schemas aren’t limited to top-level scalar fields — items can itself require every array element to match its own sub-schema:

db.createCollection('orders', {
  validator: {
    $jsonSchema: {
      bsonType: 'object',
      required: ['orderId', 'items'],
      properties: {
        orderId: { bsonType: 'string' },
        items: {
          bsonType: 'array',
          minItems: 1,
          items: {
            bsonType: 'object',
            required: ['productId', 'quantity'],
            properties: {
              productId: { bsonType: 'string' },
              quantity: { bsonType: 'int', minimum: 1 }
            }
          }
        }
      }
    }
  }
});

db.orders.insertOne({
  orderId: 'ORD-1001',
  items: [
    { productId: 'SKU-42', quantity: 2 },
    { productId: 'SKU-77', quantity: 1 }
  ]
});

Output:

{
  acknowledged: true,
  insertedId: ObjectId('66b1f301c8e4a2f001d3b9ac')
}

The nested items schema under properties.items.items runs against every element of the array individually — insert an item missing quantity, or with quantity: 0, and the whole write is rejected even though orderId and the rest of the array are fine.

How it works step by step

  1. You (or your driver / Mongoose) call insertOne, insertMany, updateOne, updateMany, or findOneAndUpdate against a collection that has a validator attached.
  2. The document is serialized to BSON as usual and sent to the primary mongod.
  3. Before the write is applied to the WiredTiger storage engine, the server evaluates the resulting document — for an insert that’s the document itself; for an update it’s the full document after the update operators ($set, $push, etc.) have been applied, not just the fields being changed — against the $jsonSchema predicate.
  4. If validationLevel is 'moderate' and the document as it existed before the update already violated the schema, the check is skipped for that document; MongoDB won’t block you from further modifying a legacy document, but it also won’t force it into compliance.
  5. If the document fails validation and validationAction is 'error', the write is rejected and the client receives a MongoServerError whose errInfo.details.schemaRulesNotSatisfied array lists exactly which keywords and properties failed — this is why writing a description on every property pays off, it’s echoed straight into that error.
  6. If validation passes (or validationAction is 'warn'), the write proceeds normally: it’s applied to the storage engine and an oplog entry is generated for replication.
  7. Secondaries apply that oplog entry directly — they never re-run the validator. Validation only ever happens once, on the primary that accepted the original write.

You can also use $jsonSchema as an ordinary query predicate, independent of any validator, inside find() or a $match stage — it matches documents that satisfy the schema. That’s useful for auditing a collection before turning on strict validation: run your candidate schema through find() and compare the count against countDocuments() on the whole collection to see how much existing data would break.

db.orders.find({
  $jsonSchema: {
    bsonType: 'object',
    required: ['orderId', 'status', 'total']
  }
}).countDocuments();

Common Mistakes

Mistake 1: Assuming a new validator retroactively fixes old data

Adding a 'strict' validator to a collection that already has non-conforming documents does not touch those documents — they’re left exactly as they are. The surprise comes later: any future update to one of those bad documents, even a completely unrelated one, is now rejected until the document is brought into compliance.

// Legacy 'orders' document is missing 'total'. After adding a strict validator
// that requires 'total', this innocent update fails even though it never
// touches 'total' at all:
db.orders.updateOne(
  { orderId: 'ORD-0007' },
  { $set: { shippedAt: new Date() } }
);
// MongoServerError: Document failed validation

Use validationLevel: 'moderate' while you backfill the legacy documents with a real migration script, then switch to 'strict' once the data is actually clean:

db.runCommand({
  collMod: 'orders',
  validator: {
    $jsonSchema: {
      bsonType: 'object',
      required: ['orderId', 'status', 'total'],
      properties: {
        orderId: { bsonType: 'string' },
        status: { enum: ['pending', 'shipped', 'delivered', 'cancelled'] },
        total: { bsonType: 'double', minimum: 0 }
      }
    }
  },
  validationLevel: 'moderate'
});

Mistake 2: Using type instead of bsonType

$jsonSchema supports the plain JSON Schema type keyword, but type checks the value’s JSON type, not its BSON type — it has no concept of Date, Decimal128, Long, or ObjectId. Fields stored as those BSON types don’t reliably satisfy type: 'number' or get validated the way you’d expect:

db.createCollection('payments', {
  validator: {
    $jsonSchema: {
      bsonType: 'object',
      properties: {
        amount: { type: 'number' },
        processedAt: { type: 'number' }
      }
    }
  }
});

Use bsonType with the actual BSON type name so the check matches what’s really stored on disk:

db.createCollection('payments', {
  validator: {
    $jsonSchema: {
      bsonType: 'object',
      properties: {
        amount: { bsonType: 'decimal' },
        processedAt: { bsonType: 'date' }
      }
    }
  }
});

Mistake 3: additionalProperties: false without accounting for _id

_id is generated and present on the document before the validator ever runs. If you lock the schema down with additionalProperties: false but forget to list _id in properties, every insert is rejected — including ones that look perfectly correct:

db.createCollection('products', {
  validator: {
    $jsonSchema: {
      bsonType: 'object',
      additionalProperties: false,
      required: ['name', 'price'],
      properties: {
        name: { bsonType: 'string' },
        price: { bsonType: 'double', minimum: 0 }
      }
    }
  }
});

// Rejected: '_id' is not listed in properties, and additionalProperties is false
db.products.insertOne({ name: 'Widget', price: 9.99 });

List _id explicitly (an empty schema means “any type, unconstrained”) whenever you use additionalProperties: false:

db.createCollection('products', {
  validator: {
    $jsonSchema: {
      bsonType: 'object',
      additionalProperties: false,
      required: ['name', 'price'],
      properties: {
        _id: {},
        name: { bsonType: 'string' },
        price: { bsonType: 'double', minimum: 0 }
      }
    }
  }
});

Best Practices

  • Attach validation when you create a collection rather than retrofitting it later — retrofitting means dealing with whatever legacy data already exists.
  • When you do have to add validation to a live collection, start with validationAction: 'warn', watch the logs, clean up violators, then switch to 'error'.
  • Prefer bsonType over type so BSON-specific types like date, decimal, long, and objectId are checked correctly.
  • Write a real description on every property — it’s returned verbatim in the validation error and makes debugging failed writes far faster.
  • Use enum for small fixed sets of allowed values (order status, roles, categories) instead of leaving the field an open string.
  • Validation and indexing are separate concerns — a validator enforces correctness on write, an index makes queries fast. Add both for fields that matter.
  • Be deliberate with additionalProperties: false; it’s a strong lock that also constrains _id and any field you add later, so update the schema every time the document shape changes.
  • Treat collMod validator changes like a schema migration in a relational database — they take effect immediately for every future write, so coordinate them with application deploys.

Practice Exercises

  • Create a products collection whose validator requires name (string), price (double, minimum 0), and category (an enum of at least three values). Try inserting a document that violates each rule individually and confirm you get a validation error each time.
  • You have a reviews collection already full of documents in production. Add a $jsonSchema validator to it with validationLevel: 'moderate' and validationAction: 'warn'. Then write a find() query using $jsonSchema as a predicate to count how many existing documents would already satisfy your new schema versus the collection’s total document count.
  • Design the items sub-schema for an orders collection where each array element must be an object with a productId (string) and a quantity (integer, minimum 1). Expected shape: properties.items has bsonType: 'array' and an items keyword holding the per-element object schema.

Summary

  • $jsonSchema attaches a validator to a collection so inserts and updates are checked against required fields, BSON types, ranges, patterns, enums, and nested array/object shapes.
  • Set it with db.createCollection() for a new collection, or db.runCommand({ collMod: ... }) for an existing one.
  • validationLevel: 'moderate' grandfathers documents that predate the validator; 'strict' (the default) checks every write.
  • validationAction: 'warn' logs violations without blocking the write, useful for a safe rollout; 'error' (the default) rejects invalid writes outright.
  • Prefer bsonType over type so BSON-specific types like dates, decimals, and ObjectIds validate correctly.
  • Validation runs once on the primary at write time — it doesn’t speed up reads and is not a substitute for indexes.