Skip to content

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.

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"]
Each write is checked against the validator; the validationAction decides whether a failure is rejected or merely logged

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.

mongosh/val-create.js
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());
{
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: 1

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:

mongosh/val-reject.js
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: double

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 stored
joined: new Double(2024) REJECTED 121 double
joined: 2 ** 31 REJECTED 121 double
joined: new Int32(2024) stored

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:

mongosh/val-levels.js
db.legacy.insertMany([{ _id: 1, name: "ok", joined: 2020 }, { _id: 2, name: 7 }, { _id: 3, joined: 1500 }]); // written before any rules existed
const 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: strict
update the VALID doc so it breaks the rules REJECTED 121
update an already INVALID doc (still invalid) REJECTED 121
insert a new invalid doc REJECTED 121
--- validationLevel: moderate
update the VALID doc so it breaks the rules REJECTED 121
update an already INVALID doc (still invalid) allowed, modified: 1
insert a new invalid doc REJECTED 121

A 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:

mongosh/val-warn.js
db.runCommand({ collMod: "members", validationAction: "warn" });
db.members.insertOne({ name: "warned", joined: "not a number" }); // stored anyway
console.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.

mongosh/val-collmod.js
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 error
relaxed : moderate warn
removed : validator = null
free-form insert stored: 1

Validation 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):

mongosh/val-bypass.js
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 121
with 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.

  • Validation is opt-in and per collection; without a validator nothing is enforced.
  • bsonType is strict: a double where you required an int fails (2 ** 31 from Node, 2024.5). Use "number" when any numeric type is fine.
  • required checks presence; properties checks type and value. Use both.
  • Add validation to an existing collection with "moderate" + "warn" so old documents do not break writes.
  • Only bsonType supports BSON types; the JSON-Schema-style type: "integer" is not supported.

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:

mongosh/val-bug.js
// AI: JSON-Schema habit - "integer" is the type for whole numbers
try { 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: true

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.

ChoiceBenefitCost
$jsonSchema + errorMalformed writes never landRules must track application code; type mismatches from drivers surface as 121
moderate + warnSafe rollout on legacy data; violations in the logBad data still gets in; someone must read the logs
strictOld invalid documents cannot be edited without fixing themBreaks updates to legacy documents
bypassDocumentValidationMigrations and bulk loadsSkips the safety net; needs a privilege under auth
No validatorTotal flexibilityMalformed data reaches production unnoticed
  • “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; use bsonType.
  • “A JavaScript integer is always an int.” - 2 ** 31 and any decimal reached the server as a double and failed bsonType: "int".
  • “moderate skips 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 collMod leaves 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.

Which bsonType keyword requires a whole number stored as a 32-bit integer?
Node.js inserts { joined: 2 ** 31 } into a collection requiring bsonType "int". What happens?
What is the difference between validationLevel "strict" and "moderate" on a collection with old invalid documents?
With validationAction: "warn", what happens to a document that breaks the rules?
How do you read WHY a write was rejected?
Which command changes or removes a validator later?