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 unlessadditionalProperties: falseis 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
- You (or your driver / Mongoose) call
insertOne,insertMany,updateOne,updateMany, orfindOneAndUpdateagainst a collection that has a validator attached. - The document is serialized to BSON as usual and sent to the primary
mongod. - 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$jsonSchemapredicate. - If
validationLevelis'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. - If the document fails validation and
validationActionis'error', the write is rejected and the client receives aMongoServerErrorwhoseerrInfo.details.schemaRulesNotSatisfiedarray lists exactly which keywords and properties failed — this is why writing adescriptionon every property pays off, it’s echoed straight into that error. - If validation passes (or
validationActionis'warn'), the write proceeds normally: it’s applied to the storage engine and an oplog entry is generated for replication. - 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
bsonTypeovertypeso BSON-specific types likedate,decimal,long, andobjectIdare checked correctly. - Write a real
descriptionon every property — it’s returned verbatim in the validation error and makes debugging failed writes far faster. - Use
enumfor 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_idand any field you add later, so update the schema every time the document shape changes. - Treat
collModvalidator 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
productscollection whose validator requiresname(string),price(double, minimum 0), andcategory(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
reviewscollection already full of documents in production. Add a$jsonSchemavalidator to it withvalidationLevel: 'moderate'andvalidationAction: 'warn'. Then write afind()query using$jsonSchemaas a predicate to count how many existing documents would already satisfy your new schema versus the collection’s total document count. - Design the
itemssub-schema for anorderscollection where each array element must be an object with aproductId(string) and aquantity(integer, minimum 1). Expected shape:properties.itemshasbsonType: 'array'and anitemskeyword holding the per-element object schema.
Summary
$jsonSchemaattaches 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, ordb.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
bsonTypeovertypeso 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.
