MongoDB will accept any document you send it. That flexibility is the point — it is what
lets you add a field without a migration window. It is also why a single bug can put
views: "12" into a collection where every other document has a number, and nothing
tells you until a total comes back wrong.
Schema validation is how you keep the flexibility where you want it and close the door where you do not. It is opt-in, nobody turns it on for you, and it takes about ten minutes to add.
What goes wrong without it
The failure is never an error. It is a number that is quietly incorrect:
db.demo.insertOne({ slug: "a", stats: { views: 12 } }) // number
db.demo.insertOne({ slug: "b", stats: { views: "12" } }) // string, from a bug
db.demo.aggregate([{ $group: { _id: null, total: { $sum: "$stats.views" } } }])
// [ { _id: null, total: 12 } ] ← the string was skipped, not addedTwelve, not twenty-four. $sum ignores values that are not numeric, and it does so
silently. A report that is wrong by an unknown amount is worse than one that fails.
Adding a validator
Validation is a property of the collection, expressed with $jsonSchema. On an
existing collection use collMod; on a new one, pass it to
createCollection:
db.runCommand({
collMod: "reels",
validator: {
$jsonSchema: {
bsonType: "object",
required: ["slug", "title", "status"],
properties: {
slug: { bsonType: "string", pattern: "^[a-z0-9-]+$" },
title: { bsonType: "string", maxLength: 140 },
status: { enum: ["DRAFT", "SCHEDULED", "PUBLISHED", "ARCHIVED"] },
stats: {
bsonType: "object",
properties: {
views: { bsonType: ["long", "int"], minimum: 0 }
}
}
}
}
},
validationLevel: "moderate",
validationAction: "error"
})Note bsonType rather than JSON Schema’s type. The difference
matters: type: "number" cannot tell an int from a long from a
double, and those are genuinely different BSON types. Listing both
"long" and "int" above is deliberate — a driver may send either for
a small whole number, and a validator that accepts only one will reject perfectly good writes.
With that in place, the bad document is refused:
db.reels.insertOne({ slug: "c", title: "x", status: "PUBLISHED", stats: { views: "12" } })
// MongoServerError: Document failed validationThe two dials
validationLevel decides which documents are checked:
strict— every insert and every update. The default.moderate— inserts, and updates to documents that already pass. Documents that were invalid before you added the rule are left alone.off— nothing.
validationAction decides what happens when a document fails:
error— reject the write. The default.warn— accept it, and log to the server log.
That pairing is what makes this usable on a collection that already has data. Turning on
strict + error against a live collection full of historical documents
breaks every update to any of them. The path that works is
moderate + warn first, watch the logs, fix the stragglers, then
tighten.
Finding what would fail, before you turn it on
You do not have to guess. $jsonSchema works as a query operator too, so you can ask
the collection directly:
const schema = { bsonType: "object", required: ["slug", "status"],
properties: { status: { enum: ["DRAFT", "SCHEDULED", "PUBLISHED", "ARCHIVED"] } } }
db.reels.countDocuments({ $jsonSchema: schema }) // documents that WOULD pass
db.reels.countDocuments({ $nor: [{ $jsonSchema: schema }] }) // documents that would FAILThere is no “does not match” operator, which is why the second line wraps the schema
in $nor — the negation of a one-element list. Run it before enabling
anything. If it returns zero you can go straight to
strict; if it does not, you now have the list to clean up rather than a production
incident to diagnose.
validate() is a different thing despite the name — it checks the
collection’s internal structure for corruption, not your documents against your rules. It is
not what you want here.
Reading the failure
“Document failed validation” on its own would be useless. The detail is in
errInfo, which the shell hides unless you ask for it — and it names the rule,
the field and the offending value:
try { db.t.insertOne({ status: "WEIRD" }) }
catch (e) { printjson(e.errInfo.details) }{
"operatorName": "$jsonSchema",
"schemaRulesNotSatisfied": [
{ "operatorName": "properties",
"propertiesNotSatisfied": [
{ "propertyName": "status",
"details": [ { "operatorName": "enum",
"reason": "value was not found in enum",
"consideredValue": "WEIRD" } ] } ] },
{ "operatorName": "required", "missingProperties": [ "slug" ] }
]
}Note that it reports every broken rule, not just the first — both the bad enum value and the missing required field. Any driver exposes the same structure, so this is the thing to log when a write is rejected in production. Logging only the message throws away the one piece of information you need.
Evolving a schema that is already live
Adding a required field to a collection with a million existing documents is the case that actually comes up. The sequence that works has four steps, and the order matters:
- Write the field first. Deploy application code that sets it on every new and updated document, while the validator still ignores it.
- Backfill. One
updateManyfor the historical documents. - Verify. The
$norquery above should return zero. - Then require it. Only now add the field to
required.
db.reels.updateMany(
{ publishedAt: { $exists: false } },
{ $set: { publishedAt: null } }
)Doing it the other way round — tightening the validator first and backfilling after — means every write to an un-backfilled document is rejected in the window between the two, which on a busy collection is an outage rather than a migration.
Where validation should not do the work
A database validator is a backstop, not your application’s input handling. It runs after the request has travelled all the way to the database, and its error message names a schema rather than a form field. ReelCMS validates at the API boundary with bean validation, where the message can be useful to a human:
public record ReelRequest(
@NotBlank @Size(max = 140) String title,
@Size(max = 90) String slug,
@Size(max = 800) String description,
ReelStatus status,
Instant scheduledFor,
String creatorId,
List<String> tags,
List<String> collectionIds,
VideoDto video) {}The two layers answer different questions. The DTO asks “is this request
well-formed?” and can say exactly which field is wrong. The collection validator asks
“could this document have come from a code path I forgot about?” — a migration
script, an admin console, a colleague in mongosh. You want both, and you want the
application layer to be the one users actually hit.
What it cannot do
Validation is per-document and per-write. It cannot express a rule that spans documents — “this slug is unique” is a unique index, not a validator, and “this creatorId exists” is not expressible at all, because MongoDB has no foreign keys. Referential integrity remains the application’s job no matter how good your schema is.
The short version
- Flexible by default means wrong types get in silently, and aggregations skip them.
- Use
$jsonSchemawithbsonType, not JSON Schema’stype. - Accept both
"int"and"long"for whole numbers. - On existing data:
moderate+warn, then tighten. - Query with
{ $nor: [{ $jsonSchema }] }to list what would fail first. - Validate in the application for the message; validate in the database for the paths you forgot.
- Uniqueness is an index. Foreign keys do not exist.