Schema validation
MongoDB is schema-flexible by default: a collection accepts documents of wildly different shapes, which is freeing during prototyping and dangerous in production. Schema validation lets you opt back into enforcement - which fields must exist, what types they must be, what values are allowed - without giving up the document model where you still want it. You attach rules with a $jsonSchema validator and the server checks every write against them.
Everything below ran on mongo 8.3.11, mongosh 2.11.1 and Node driver 7.7.0 on one Mac; Python, Go and Rust tabs were each run once in a scratch program. The lesson creates its own members collection with the exact validator shown.
What a validator looks like
Section titled “What a validator looks like”A validator is a query expression every document must satisfy. $jsonSchema builds one from three core keywords: bsonType (the type a field or the document must be: "object", "string", "int", "array"), required (field names that must be present) and properties (a per-field map of type and constraints). Below: every member needs a string name and an integer joined year of at least 1900, and fines, if present, must be a non-negative number.
flowchart LR
W["Incoming write"] --> V{"Matches $jsonSchema?"}
V -->|"Yes"| A["Accepted and stored"]
V -->|"No, action error"| R["Rejected with a validation error"]
V -->|"No, action warn"| L["Stored but logged as a warning"] Creating a collection with a validator
Section titled “Creating a collection with a validator”Attaching a validator is part of creating the collection (collMod can add one later). The mongosh tab prints back the stored options; the Node.js tab is the type-trap demonstration covered below.
db.createCollection("members", { validator: { $jsonSchema: { bsonType: "object", required: ["name", "joined"], properties: { name: { bsonType: "string" }, joined: { bsonType: "int", minimum: 1900 }, fines: { bsonType: "number", minimum: 0 } } } }, validationLevel: "strict", validationAction: "error"});console.log(db.getCollectionInfos({ name: "members" })[0].options);db.members.insertOne({ name: "Ada", joined: 2024, fines: 0 });console.log("valid document stored:", db.members.countDocuments());const members = db.collection("members");await db.createCollection("members", { validator: { $jsonSchema: { bsonType: "object", required: ["name", "joined"], properties: { name: { bsonType: "string" }, joined: { bsonType: "int", minimum: 1900 } } } } });const tryInsert = async (label, doc) => { try { await members.insertOne(doc); console.log(label.padEnd(24), "stored"); return true; } catch (e) { console.log(label.padEnd(24), "REJECTED", e.code, e.errInfo.details.schemaRulesNotSatisfied[0].propertiesNotSatisfied[0].details[0].consideredType); return false; } };const a = await tryInsert("joined: 2024", { name: "a", joined: 2024 });const b = await tryInsert("joined: new Double(2024)", { name: "b", joined: new Double(2024) });const c = await tryInsert("joined: 2 ** 31", { name: "c", joined: 2 ** 31 });const d = await tryInsert("joined: new Int32(2024)", { name: "d", joined: new Int32(2024) });if (!(a && !b && !c && d)) throw new Error("unexpected type behaviour");db.create_collection( "members", validator={"$jsonSchema": { "bsonType": "object", "required": ["name", "joined"], "properties": { "name": {"bsonType": "string"}, "joined": {"bsonType": "int", "minimum": 1900}, "fines": {"bsonType": "number", "minimum": 0}, }, }}, validationLevel="strict", validationAction="error",)validator := bson.M{"$jsonSchema": bson.M{ "bsonType": "object", "required": bson.A{"name", "joined"}, "properties": bson.M{ "name": bson.M{"bsonType": "string"}, "joined": bson.M{"bsonType": "int", "minimum": 1900}, "fines": bson.M{"bsonType": "number", "minimum": 0}, },}}opts := options.CreateCollection(). SetValidator(validator). SetValidationLevel("strict"). SetValidationAction("error")if err := db.CreateCollection(ctx, "members", opts); err != nil { return err}db.create_collection("members") .validator(doc! { "$jsonSchema": { "bsonType": "object", "required": ["name", "joined"], "properties": { "name": { "bsonType": "string" }, "joined": { "bsonType": "int", "minimum": 1900 }, "fines": { "bsonType": "number", "minimum": 0 }, }, } }) .validation_level(ValidationLevel::Strict) .validation_action(ValidationAction::Error) .await?;{ validator: { '$jsonSchema': { bsonType: 'object', required: [ 'name', 'joined' ], properties: { name: { bsonType: 'string' }, joined: { bsonType: 'int', minimum: 1900 }, fines: { bsonType: 'number', minimum: 0 } } } }, validationLevel: 'strict', validationAction: 'error'}valid document stored: 1A rejected write, and how to read why
Section titled “A rejected write, and how to read why”A document that breaks a rule is rejected with error code 121 and the bare message Document failed validation. The reason is in error.errInfo.details, which lists every rule that failed with the value and type the server considered:
try { db.members.insertOne({ name: 42 }); }catch (e) { console.log(e.code, e.codeName || "", "-", e.message); console.log(JSON.stringify(e.errInfo.details.schemaRulesNotSatisfied, null, 1)); }try { db.members.insertOne({ name: "Zed", joined: 1800, fines: -1 }); }catch (e) { console.log("second try:", JSON.stringify(e.errInfo.details.schemaRulesNotSatisfied[0].propertiesNotSatisfied.map((p) => p.propertyName + ": " + p.details[0].operatorName + " " + p.details[0].reason))); }try { db.members.insertOne({ name: "Zed", joined: 2024.5 }); }catch (e) { console.log("third try :", e.errInfo.details.schemaRulesNotSatisfied[0].propertiesNotSatisfied[0].details[0].reason, "- considered type:", e.errInfo.details.schemaRulesNotSatisfied[0].propertiesNotSatisfied[0].details[0].consideredType); }assert.strictEqual(db.members.countDocuments(), 1);121 - Document failed validation[ { "operatorName": "properties", "propertiesNotSatisfied": [ { "propertyName": "name", "details": [ { "operatorName": "bsonType", "specifiedAs": { "bsonType": "string" }, "reason": "type did not match", "consideredValue": 42, "consideredType": "int" } ] } ] }, { "operatorName": "required", "specifiedAs": { "required": [ "name", "joined" ] }, "missingProperties": [ "joined" ] }]second try: ["joined: minimum comparison failed","fines: minimum comparison failed"]third try : type did not match - considered type: doubleThe number-type trap
Section titled “The number-type trap”bsonType is stricter than JSON: "int", "long", "double" and "decimal" are distinct, and "number" accepts any of them. Which one the server sees depends on the driver. mongosh stores 2024 as an int; the Node.js driver stores JavaScript numbers inside the 32-bit range as int32 and everything else (a decimal, or 2 ** 31) as a double:
joined: 2024 storedjoined: new Double(2024) REJECTED 121 doublejoined: 2 ** 31 REJECTED 121 doublejoined: new Int32(2024) storedvalidationLevel and validationAction
Section titled “validationLevel and validationAction”Two knobs control how the rules bite. validationLevel decides which writes are checked: "strict" (default) checks every insert and update; "moderate" checks inserts and updates to documents that already satisfy the rules, and leaves existing invalid documents alone. validationAction decides what happens on failure: "error" (default) rejects; "warn" allows the write and logs it. Three documents written before any rules existed (one valid, two invalid), under each level:
db.legacy.insertMany([{ _id: 1, name: "ok", joined: 2020 }, { _id: 2, name: 7 }, { _id: 3, joined: 1500 }]); // written before any rules existedconst rules = { $jsonSchema: { bsonType: "object", required: ["name", "joined"], properties: { name: { bsonType: "string" }, joined: { bsonType: "int", minimum: 1900 } } } };const attempt = (label, f) => { try { const r = f(); console.log(label.padEnd(46), "allowed, modified:", r.modifiedCount ?? r.insertedId ?? "yes"); } catch (e) { console.log(label.padEnd(46), "REJECTED", e.code); } };for (const level of ["strict", "moderate"]) { db.runCommand({ collMod: "legacy", validator: rules, validationLevel: level, validationAction: "error" }); console.log("--- validationLevel:", level); attempt("update the VALID doc so it breaks the rules", () => db.legacy.updateOne({ _id: 1 }, { $set: { joined: 1500 } })); db.legacy.updateOne({ _id: 1 }, { $set: { name: "ok", joined: 2020 } }, { bypassDocumentValidation: true }); attempt("update an already INVALID doc (still invalid)", () => db.legacy.updateOne({ _id: 2 }, { $set: { note: "x" } })); attempt("insert a new invalid doc", () => db.legacy.insertOne({ name: 1 })); db.legacy.deleteMany({ name: 1 });}--- validationLevel: strictupdate the VALID doc so it breaks the rules REJECTED 121update an already INVALID doc (still invalid) REJECTED 121insert a new invalid doc REJECTED 121--- validationLevel: moderateupdate the VALID doc so it breaks the rules REJECTED 121update an already INVALID doc (still invalid) allowed, modified: 1insert a new invalid doc REJECTED 121A common rollout is "moderate" plus "warn" first, watch the logs to learn how much existing data violates the rules, fix the offenders, then tighten to "strict" and "error". The real warn log line:
db.runCommand({ collMod: "members", validationAction: "warn" });db.members.insertOne({ name: "warned", joined: "not a number" }); // stored anywayconsole.log("stored despite the rules:", db.members.countDocuments({ name: "warned" }));const line = db.adminCommand({ getLog: "global" }).log.filter((l) => l.includes("Document would fail validation")).pop();const j = JSON.parse(line);console.log({ severity: j.s, component: j.c, id: j.id, msg: j.msg, namespace: j.attr.namespace.split(".").pop(), firstBrokenRule: j.attr.errInfo.details.schemaRulesNotSatisfied[0].operatorName });db.runCommand({ collMod: "members", validationAction: "error" });assert.strictEqual(j.id, 20294);stored despite the rules: 1{ severity: 'W', component: 'STORAGE', id: 20294, msg: 'Document would fail validation', namespace: 'members', firstBrokenRule: 'properties'}The same line is written to the mongod log (docker logs <container> shows it), and getLog reads it through the shell.
Changing rules later: collMod and bypass
Section titled “Changing rules later: collMod and bypass”const opts = () => db.getCollectionInfos({ name: "members" })[0].options;console.log("now :", opts().validationLevel, opts().validationAction);db.runCommand({ collMod: "members", validationLevel: "moderate", validationAction: "warn" });console.log("relaxed :", opts().validationLevel, opts().validationAction);db.runCommand({ collMod: "members", validator: {} });console.log("removed :", "validator =", JSON.stringify(opts().validator ?? null));db.members.insertOne({ anything: "goes" });console.log("free-form insert stored:", db.members.countDocuments({ anything: "goes" }));db.members.deleteOne({ anything: "goes" });now : strict errorrelaxed : moderate warnremoved : validator = nullfree-form insert stored: 1Validation is not a one-way door. For trusted bulk loads or migrations, the bypassDocumentValidation option skips the rules for that write (it is a privilege when authentication is on):
db.runCommand({ collMod: "members", validator: { $jsonSchema: { bsonType: "object", required: ["name", "joined"], properties: { name: { bsonType: "string" }, joined: { bsonType: "int", minimum: 1900 } } } }, validationLevel: "strict", validationAction: "error" });const doc = { name: "migration", joined: 1000 };try { db.members.insertOne(doc); } catch (e) { console.log("normal insert :", "REJECTED", e.code); }const r = db.runCommand({ insert: "members", documents: [doc], bypassDocumentValidation: true });console.log("with bypassDocumentValidation: n =", r.n, "(needs the bypassDocumentValidation privilege when auth is on)");assert.strictEqual(r.n, 1);normal insert : REJECTED 121with bypassDocumentValidation: n = 1 (needs the bypassDocumentValidation privilege when auth is on)In Compass: open a collection and go to the Validation tab. You can paste or edit a $jsonSchema document, pick the level and action from dropdowns, and Compass previews which existing documents would pass or fail before you save.
Tips and gotchas
Section titled “Tips and gotchas”- Validation is opt-in and per collection; without a validator nothing is enforced.
bsonTypeis strict: a double where you required an int fails (2 ** 31from Node,2024.5). Use"number"when any numeric type is fine.requiredchecks presence;propertieschecks type and value. Use both.- Add validation to an existing collection with
"moderate"+"warn"so old documents do not break writes. - Only
bsonTypesupports BSON types; the JSON-Schema-styletype: "integer"is not supported.
Reading AI-generated code
Section titled “Reading AI-generated code”Asked for a validator for whole-number join years, an assistant reaches for JSON-Schema vocabulary:
🐛 Spot the bug
// AI comment: "integer is the JSON Schema type for whole numbers"await db.createCollection("strictMembers", {validator: { $jsonSchema: { bsonType: "object", properties: { joined: { type: "integer", minimum: 1900 } } } },});Show the answer
MongoDB implements a subset of JSON Schema with BSON extensions: the type keyword integer is rejected when the collection is created (FailedToParse: $jsonSchema type 'integer' is not currently supported). The BSON name is bsonType: "int" (or "long", or "number" for any numeric type). Note the second-order trap from the Node.js tab: with "int", a value your driver encodes as a double will be rejected at runtime, so validate what your driver actually sends:
// AI: JSON-Schema habit - "integer" is the type for whole numberstry { db.createCollection("strictMembers", { validator: { $jsonSchema: { bsonType: "object", properties: { joined: { type: "integer", minimum: 1900 } } } } }); }catch (e) { console.log(e.codeName + ":", e.message); }db.createCollection("strictMembers", { validator: { $jsonSchema: { bsonType: "object", properties: { joined: { bsonType: "int", minimum: 1900 } } } } });console.log("with bsonType: 'int' it works:", db.getCollectionInfos({ name: "strictMembers" }).length === 1);FailedToParse: $jsonSchema type 'integer' is not currently supported.with bsonType: 'int' it works: trueMental Model
Section titled “Mental Model”A validator is a filter on the write path: every insert and update is tested against a query-shaped rule before it lands. validationLevel picks which writes face the test, validationAction picks the consequence, and collMod moves the dials. The rules see BSON types, not JavaScript types - so the driver you use is part of the schema.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
$jsonSchema + error | Malformed writes never land | Rules must track application code; type mismatches from drivers surface as 121 |
moderate + warn | Safe rollout on legacy data; violations in the log | Bad data still gets in; someone must read the logs |
strict | Old invalid documents cannot be edited without fixing them | Breaks updates to legacy documents |
bypassDocumentValidation | Migrations and bulk loads | Skips the safety net; needs a privilege under auth |
| No validator | Total flexibility | Malformed data reaches production unnoticed |
Common Misconceptions
Section titled “Common Misconceptions”- “A validator replaces application validation.” - it checks shape and simple constraints, not rules like uniqueness (use a unique index).
- “
type: 'integer'works like in JSON Schema.” - it fails to parse; usebsonType. - “A JavaScript integer is always an int.” -
2 ** 31and any decimal reached the server as a double and failedbsonType: "int". - “
moderateskips validation.” - inserts of invalid documents and breaking updates to valid ones were still rejected. - “The validator syncs with my code.” - it does not; changing the app without
collModleaves the old rules.
Real-world example
The MongoDB manual defines the behaviour measured above: under “moderate”, “updates to existing documents in the collection that don’t match the validation rules aren’t required to pass validation”, while “strict” “applies the same validation rules to all document inserts and updates” (validation level, consulted 2026-09-30).
Python/Go/Rust tabs mirror the verified shell/Node behaviour; each was run once in a scratch program against the same server (the collection was created with the validator and a bad document was rejected) but they are not executed by the course’s automated checker.