MongoDB is often called schemaless. A better description is that the schema lives wherever you put it. By default it’s only in application code, which works until a second service, a migration script or a hurried manual fix writes price: "19.99" as a string. Then sums break and sorts go strange. Schema validation moves the rules you actually care about into the database, so every writer has to follow them.
Examples are mongosh for MongoDB 7 and 8. They’re illustrative; no live server was available while writing.
A validator on a new collection
db.createCollection("products", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["sku", "name", "price", "status"],
properties: {
sku: { bsonType: "string", pattern: "^[A-Z0-9-]{4,20}$" },
name: { bsonType: "string", minLength: 1 },
price: { bsonType: "decimal", minimum: 0 },
status: { enum: ["draft", "active", "retired"] },
tags: { bsonType: "array", items: { bsonType: "string" }, maxItems: 20 }
}
}
}
})A few choices here worth explaining:
bsonTyperather thantype. MongoDB’s$jsonSchemasupports BSON types such asdecimal,date,objectIdandlong, which plain JSON Schema doesn’t have. Money asdecimal(Decimal128) avoids floating-point rounding.requiredlists only fields every product must have.tagsis optional, but if present it must be an array of strings.- There’s no
additionalProperties: false, so products can carry extra fields (color,weightGrams, category-specific attributes). That’s the flexible part.
Now a bad insert:
db.products.insertOne({ sku: "TR-300", name: "Trail Runner", price: "89.00", status: "active" })MongoServerError: Document failed validation
Additional information: {
failingDocumentId: ...,
details: { operatorName: '$jsonSchema', schemaRulesNotSatisfied: [
{ operatorName: 'properties', propertiesNotSatisfied: [
{ propertyName: 'price', details: [ { operatorName: 'bsonType',
specifiedAs: { bsonType: 'decimal' }, reason: 'type did not match', consideredValue: '89.00', consideredType: 'string' } ] } ] } ] }
}The error code is 121 (DocumentValidationFailure), and since MongoDB 5.0 the details tell you exactly which rule failed, which makes debugging far easier than it used to be. The fix on the client is price: NumberDecimal("89.00").
When does validation run?
Validation runs on inserts, updates and replacements. On an update, MongoDB validates the document as it would look after the update. So $set: { price: -5 } fails the minimum: 0 rule, and $unset: { name: "" } fails required.
It doesn’t run on reads, and it doesn’t scan existing documents when you add a validator. That second point is important when you add validation to a collection that’s been in production for a while.
Adding validation to existing data
Suppose products already has 2 million documents, and some unknown number have string prices. If you add a strict validator, every update to one of those old documents will fail, even an unrelated one like changing status, because the post-update document still has a string price. That can break a production flow instantly.
Two settings help:
validationLevel: "moderate"applies rules to inserts and to updates of documents that are already valid. Updates to documents that were invalid before are allowed through. The default is"strict", which validates every insert and update.validationAction: "warn"logs violations instead of rejecting the write. The default is"error".
A safe rollout looks like this:
// 1. find out how bad it is
db.products.countDocuments({ $nor: [ { $jsonSchema: productSchema } ] })
// 2. turn validation on in warn mode, watch the logs
db.runCommand({ collMod: "products", validator: { $jsonSchema: productSchema },
validationLevel: "moderate", validationAction: "warn" })
// 3. fix the data in batches, e.g. convert string prices
db.products.updateMany({ price: { $type: "string" } },
[ { $set: { price: { $toDecimal: "$price" } } } ])
// 4. tighten
db.runCommand({ collMod: "products", validationLevel: "strict", validationAction: "error" })The $nor with $jsonSchema query in step 1 is a neat trick: $jsonSchema works as a normal query operator, so you can find the documents that would fail before you enforce anything.
Keeping flexibility on purpose
The point isn’t to turn MongoDB into a rigid table. Validate what your code relies on: identifiers, types of numeric and date fields used in queries, enum values that drive logic, required fields. Leave descriptive or category-specific attributes open. Some patterns:
- Use
oneOforanyOffor genuinely polymorphic documents, such as apaymentscollection where card payments needlast4and bank transfers neediban, keyed by amethodfield. - Put open-ended attributes under one subdocument (
attributes: { ... }) and validate only that it’s an object. That keeps the top level tidy. - Set
additionalProperties: falseonly when you’re sure; it also forces you to list_id, which surprises people the first time a perfectly normal insert fails.
Checking what’s currently enforced
Validators are easy to forget once they’re in place, and a new developer may not know one exists until a write fails. You can read the current rules back:
db.getCollectionInfos({ name: "products" })[0].options
// { validator: { $jsonSchema: { ... } }, validationLevel: 'strict', validationAction: 'error' }Keep the schema itself in version control, next to the migration that applies it with collMod, so changes are reviewed like code. Treat loosening a rule (adding an enum value, making a field optional) as safe to deploy before the code that uses it, and tightening a rule as something to deploy after every writer has been updated and the data backfilled.
Mistakes and limits
- Validation isn’t a uniqueness check. “Each SKU must be unique” needs a unique index, not a schema rule.
- It can be bypassed. Users with the
bypassDocumentValidationprivilege, and operations that set that option, skip validation. Restore and some migration tools use it. - It isn’t a replacement for app-side validation. Users need friendly field-level error messages, which you produce in the application. The database validator is the backstop that catches every other writer.
- Cross-document rules don’t belong here. A validator sees one document. “Order total equals the sum of payments in another collection” can’t be checked this way.
Interview practice
Your team owns a users collection written by three services. Analytics reports that createdAt is sometimes a string, sometimes a date, and sometimes missing. You’re asked to “add schema validation” without breaking any service. Walk through your plan.
Answer
I’d start by measuring: count documents where createdAt is a date, a string and missing, using $type in a $group, so I know how much data needs fixing and roughly which service wrote it (other fields or a source field usually reveal that).
Next I’d add a validator requiring createdAt with bsonType: "date", but with validationLevel: "moderate" and validationAction: "warn". Nothing gets rejected; the server logs every write that would fail. Over a few days those log entries identify which service still writes strings or omits the field, and the team fixes that service.
While that happens, I’d backfill in batches: convert strings with $dateFromString in an update pipeline and set a sensible value for missing ones (perhaps derived from the _id timestamp with $toDate: "$_id", which is accurate to the second for default ObjectIds). Once logs show no new violations and the backfill is complete, I’d switch to strict and error. Then any new bad write fails immediately with code 121, which surfaces the bug at the writer instead of in a report weeks later.