
Schema Validation in MongoDB with JSON Schema
MongoDB's flexible schema is a real advantage early in a project. You can add a field without a migration, store different shapes of documents side by side, and iterate quickly. But flexibility has a cost that shows up later: a price stored as a string in 3% of your products, an email field that's sometimes Email, a status of "actve" from a typo in an admin script. Each one is small. Together, they make every query and report a little less trustworthy.
Application-level validation, like a Mongoose schema or a Zod parser, catches a lot of this, but only for writes that go through that code. Migration scripts, a second service in another language, a teammate fixing data in mongosh, and bulk imports all bypass it. Schema validation puts the rules in the database itself. You attach a validator to a collection, usually written with the $jsonSchema operator, and MongoDB checks every insert and update against it.
This guide covers writing $jsonSchema validators, the BSON types and keywords you'll use most, adding validation to existing collections, validation levels and actions, reading the detailed error output, finding documents that don't comply, and how database validation fits with validation in your application.
A First Validator
You can attach a validator when creating a collection:
db.createCollection("users", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["email", "name", "createdAt"],
properties: {
email: {
bsonType: "string",
pattern: "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$",
description: "must be a valid email address",
},
name: {
bsonType: "string",
minLength: 1,
maxLength: 100,
},
role: {
enum: ["member", "admin", "owner"],
description: "must be one of member, admin, owner",
},
age: {
bsonType: "int",
minimum: 13,
maximum: 130,
},
createdAt: {
bsonType: "date",
},
},
},
},
});
Now try inserting something that breaks the rules:
db.users.insertOne({
email: "not-an-email",
name: "Ada",
createdAt: new Date(),
});
MongoServerError: Document failed validation
Additional information: {
failingDocumentId: ObjectId("66f2b8e1c9a4d31f2a7e0b11"),
details: {
operatorName: '$jsonSchema',
schemaRulesNotSatisfied: [
{
operatorName: 'properties',
propertiesNotSatisfied: [
{
propertyName: 'email',
description: 'must be a valid email address',
details: [
{
operatorName: 'pattern',
specifiedAs: { pattern: '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$' },
reason: 'regular expression did not match',
consideredValue: 'not-an-email'
}
]
}
]
}
]
}
}
The error tells you exactly which property failed, which rule it broke, and the value that was considered. Detailed validation errors like this were added in MongoDB 5.0; before that, you just got "Document failed validation" and had to guess. The description you write on each property shows up in the error, so write descriptions that help whoever sees them.
A valid insert goes through as normal:
db.users.insertOne({
email: "ada@example.com",
name: "Ada",
role: "admin",
createdAt: new Date(),
});
Note that age isn't required. Properties listed in properties but not in required are only checked if they're present. That's usually what you want: optional fields are allowed to be missing, but if they exist, they must be the right type.
bsonType, Not Just type
Standard JSON Schema has a type keyword with JSON's types: string, number, object, array, boolean, null. MongoDB supports type but adds bsonType, which understands BSON's richer type system. In practice you'll use bsonType almost everywhere, because JSON's number can't tell an int from a double from a Decimal128, and JSON has no date or ObjectId.
| bsonType | Stores | Notes |
|---|---|---|
string | UTF-8 text | |
int | 32-bit integer | NumberInt() in mongosh |
long | 64-bit integer | NumberLong() |
double | 64-bit floating point | Default for JavaScript numbers |
decimal | Decimal128 | Use for money |
number | Any of int, long, double, decimal | Handy alias |
bool | true or false | Note: bool, not boolean |
date | BSON date | |
objectId | ObjectId | |
object | Embedded document | |
array | Array | |
null | Null |
The int versus double distinction bites people constantly. In mongosh and the Node.js driver, a plain number like 42 is sent as a double unless you wrap it. So with age: { bsonType: "int" }, this fails:
db.users.insertOne({
email: "sam@example.com",
name: "Sam",
age: 34,
createdAt: new Date(),
});
// Document failed validation: bsonType 'int' ... consideredValue: 34 (double)
Either insert NumberInt(34), or, more practically, validate with bsonType: "number" or ["int", "long", "double"] unless the exact storage type matters. For money, require decimal, as covered in storing money with Decimal128.
bsonType can be an array to allow several types, which is the standard way to make a field nullable:
deletedAt: {
bsonType: ["date", "null"];
}
Nested Documents and Arrays
Real documents have structure. $jsonSchema validates nested objects with nested properties, and arrays with items:
const orderSchema = {
bsonType: "object",
required: ["customerId", "status", "items", "total", "createdAt"],
properties: {
customerId: { bsonType: "objectId" },
status: { enum: ["pending", "paid", "shipped", "cancelled"] },
shippingAddress: {
bsonType: "object",
required: ["line1", "city", "country"],
properties: {
line1: { bsonType: "string" },
line2: { bsonType: "string" },
city: { bsonType: "string" },
postcode: { bsonType: "string" },
country: { bsonType: "string", minLength: 2, maxLength: 2 },
},
additionalProperties: false,
},
items: {
bsonType: "array",
minItems: 1,
maxItems: 200,
items: {
bsonType: "object",
required: ["sku", "qty", "unitPrice"],
properties: {
sku: { bsonType: "string" },
qty: { bsonType: "int", minimum: 1 },
unitPrice: { bsonType: "decimal" },
},
},
},
total: { bsonType: "decimal" },
tags: {
bsonType: "array",
uniqueItems: true,
items: { bsonType: "string" },
},
createdAt: { bsonType: "date" },
},
};
db.createCollection("orders", { validator: { $jsonSchema: orderSchema } });
This enforces that every order has at least one line item, every line item has a positive integer quantity and a decimal price, the address uses a two-letter country code, and tags don't repeat. For more on how nested fields and arrays behave in queries, see working with nested documents and dot notation.
additionalProperties and the _id Trap
additionalProperties: false rejects any field not listed in properties. It's great for tightly controlled sub-documents like the address above. At the top level, it has a catch: every document has an _id, so if you don't list it, every insert fails:
// Top-level strict schema: _id must be listed
{
bsonType: "object",
additionalProperties: false,
required: ["email"],
properties: {
_id: { bsonType: "objectId" },
email: { bsonType: "string" }
}
}
Think carefully before using additionalProperties: false at the top level. It removes one of MongoDB's main advantages, adding fields without a schema change, and every new field then requires updating the validator first. It's often better to validate the fields you care about and leave the rest open.
Beyond $jsonSchema: Query Operators in Validators
A validator isn't limited to $jsonSchema. It can be any query filter, and it can combine $jsonSchema with other operators. That's how you express rules JSON Schema can't, like comparing two fields with $expr:
db.runCommand({
collMod: "promotions",
validator: {
$and: [
{
$jsonSchema: {
bsonType: "object",
required: ["code", "startsAt", "endsAt", "discountPct"],
properties: {
code: { bsonType: "string", pattern: "^[A-Z0-9]{4,16}$" },
startsAt: { bsonType: "date" },
endsAt: { bsonType: "date" },
discountPct: { bsonType: "number", minimum: 1, maximum: 90 },
},
},
},
{ $expr: { $lt: ["$startsAt", "$endsAt"] } },
],
},
});
Now a promotion that ends before it starts is rejected. A few operators can't be used in validators, including $near, $nearSphere, $text, and $where.
Some JSON Schema keywords aren't supported by MongoDB's implementation, notably $ref, $schema, default, definitions, format, and id. The lack of $ref means you can't define a sub-schema once and reuse it. The workaround is to build the schema in code, where you can reuse JavaScript objects, and pass the result to the database:
const money = { bsonType: "decimal" };
const address = {
bsonType: "object",
required: ["line1", "city", "country"],
properties: {
line1: { bsonType: "string" },
city: { bsonType: "string" },
country: { bsonType: "string", minLength: 2, maxLength: 2 },
},
};
const customerSchema = {
bsonType: "object",
properties: {
billingAddress: address,
shippingAddress: address,
creditBalance: money,
},
};
Adding Validation to an Existing Collection
Most teams add validation after a collection already has data. Use collMod (here userSchema is the $jsonSchema object from the first example, stored in a variable):
db.runCommand({
collMod: "users",
validator: { $jsonSchema: userSchema },
validationLevel: "moderate",
validationAction: "error",
});
Adding a validator doesn't touch existing documents. MongoDB doesn't scan the collection or reject anything already stored. The rules apply to future writes, and how strictly depends on the validation level.
Validation Levels
validationLevel | Inserts checked? | Updates to valid docs checked? | Updates to invalid docs checked? |
|---|---|---|---|
strict (default) | Yes | Yes | Yes |
moderate | Yes | Yes | No |
off | No | No | No |
moderate is the key to rolling out validation safely. New documents must comply, documents that already comply must stay compliant, but legacy documents that don't match yet can still be updated without being forced to fix every problem at once. Once you've cleaned up the old data, switch to strict.
Validation Actions
validationAction | Behavior on invalid write |
|---|---|
error (default) | Rejects the write with a validation error |
warn | Allows the write and logs a warning in the server log |
warn is a dry run. Turn it on first, let production traffic flow for a few days, and read the logs to see which writes would have failed and why. Then fix the offending code paths and switch to error.
A safe rollout looks like this:
- Add the validator with
validationAction: "warn". - Review warnings and fix the application code that produces invalid writes.
- Find and fix existing invalid documents (next section).
- Switch to
validationAction: "error"withvalidationLevel: "moderate". - Once no invalid documents remain, switch to
validationLevel: "strict".
Finding Documents That Don't Match
Since $jsonSchema is a query operator, you can use it in find. Wrap it in $nor to find everything that fails:
db.users.find({ $nor: [{ $jsonSchema: userSchema }] });
db.users.countDocuments({ $nor: [{ $jsonSchema: userSchema }] });
// 1284
To group failures by cause, check individual rules:
db.users.aggregate([
{ $match: { $nor: [{ $jsonSchema: userSchema }] } },
{
$project: {
missingEmail: { $eq: [{ $type: "$email" }, "missing"] },
emailNotString: {
$and: [
{ $ne: [{ $type: "$email" }, "missing"] },
{ $ne: [{ $type: "$email" }, "string"] },
],
},
badRole: {
$not: [
{
$in: [
{ $ifNull: ["$role", "member"] },
["member", "admin", "owner"],
],
},
],
},
createdAtType: { $type: "$createdAt" },
},
},
{
$group: {
_id: null,
missingEmail: { $sum: { $cond: ["$missingEmail", 1, 0] } },
emailNotString: { $sum: { $cond: ["$emailNotString", 1, 0] } },
badRole: { $sum: { $cond: ["$badRole", 1, 0] } },
createdAtTypes: { $addToSet: "$createdAtType" },
},
},
]);
That gives you a to-do list for data cleanup. Fixes are usually straightforward updates, such as converting string dates:
db.users.updateMany({ createdAt: { $type: "string" } }, [
{ $set: { createdAt: { $toDate: "$createdAt" } } },
]);
Running cleanups like this as versioned scripts, rather than ad hoc in a shell, is covered in database migrations in MongoDB.
Viewing and Removing a Validator
To see the current rules:
db.getCollectionInfos({ name: "users" })[0].options;
{
validator: { '$jsonSchema': { bsonType: 'object', required: [ 'email', 'name', 'createdAt' ], ... } },
validationLevel: 'moderate',
validationAction: 'error'
}
To remove validation entirely, set an empty validator:
db.runCommand({ collMod: "users", validator: {} });
Handling Validation Errors in Application Code
Validation failures come back with error code 121 (DocumentValidationFailed). Catch it specifically and turn it into a useful response. In Node.js:
import { MongoClient, MongoServerError } from "mongodb";
const client = new MongoClient(process.env.MONGODB_URI);
const users = client.db("app").collection("users");
export async function createUser(input) {
try {
const { insertedId } = await users.insertOne({
...input,
createdAt: new Date(),
});
return { ok: true, id: insertedId };
} catch (err) {
if (err instanceof MongoServerError && err.code === 121) {
const rules = err.errInfo?.details?.schemaRulesNotSatisfied ?? [];
const fields = rules.flatMap((r) =>
(r.propertiesNotSatisfied ?? []).map((p) => ({
field: p.propertyName,
message: p.description ?? "invalid value",
})),
);
const missing = rules.flatMap((r) => r.missingProperties ?? []);
return { ok: false, fields, missing };
}
throw err;
}
}
The errInfo.details object has the same structure you saw in the shell. In PyMongo, the error is a WriteError with the same details:
from pymongo import MongoClient
from pymongo.errors import WriteError
users = MongoClient("mongodb://localhost:27017")["app"]["users"]
try:
users.insert_one({"email": 42, "name": "Ada"})
except WriteError as e:
if e.code == 121:
details = e.details.get("errInfo", {}).get("details", {})
print("validation failed:", details.get("schemaRulesNotSatisfied"))
else:
raise
With bulk inserts, validation failures appear per document in the bulk write error, so with ordered: false the valid documents still get inserted and you can report the rest.
Bypassing Validation
Users with the bypassDocumentValidation privilege can skip validation on a write by passing bypassDocumentValidation: true. That's occasionally useful for restores or one-off migrations of known-legacy data. mongorestore has a --bypassDocumentValidation flag for the same reason. Don't grant this privilege to application users; the whole point is that the rules apply no matter who writes.
Database Validation vs. Application Validation
Schema validation in the database doesn't replace validation in your application. They do different jobs:
| Concern | Application (Mongoose, Zod, Pydantic) | Database ($jsonSchema) |
|---|---|---|
| Friendly, localized error messages | Strong | Weak |
| Defaults, type coercion, transformations | Yes | No |
| Validation against other data or external APIs | Yes | No |
| Applies to every writer (scripts, other services) | No | Yes |
| Protects against bugs in your own validation | No | Yes |
The best setup uses both. Validate in the application for good user experience, and validate in the database as a safety net for structural invariants: required fields, types, enums, and value ranges. Keep the database schema a little looser than the application schema. If the database rejects something your application happily accepts, users see a generic 500 error. If the database is the looser of the two, it only catches things that genuinely should never happen.
If you use Mongoose, remember that Mongoose validation runs only on save() and on update queries with runValidators: true, and insertMany and raw driver calls behave differently. Database validation closes those gaps. Mongoose schemas, models, and middleware covers the application side in depth.
Performance
Validation adds a small amount of CPU work to each write, proportional to the size and complexity of the schema and the document. For typical schemas, it's negligible compared with the cost of the write itself. Very complex patterns, deep nesting, and large arrays with per-item rules cost more, so if you validate a 10,000-element array with a regex per item, measure it. Reads are never affected.
Common Pitfalls
Using bsonType: "int" for JavaScript numbers. Plain numbers are doubles. Use number or wrap values in NumberInt().
Forgetting _id with additionalProperties: false. Every insert fails. List _id in properties, or avoid strict top-level schemas.
Writing boolean instead of bool. The BSON type name is bool. type: "boolean" works for the JSON Schema type keyword, but bsonType: "boolean" is invalid.
Turning on strict validation over messy data. Updates to legacy documents start failing in unexpected places. Roll out with warn, then moderate, then strict.
Relying on unsupported keywords. format: "email" and $ref aren't supported. Use pattern for formats and compose schemas in code.
Making the database stricter than the app. Users get opaque errors for input your API accepted. Keep database rules focused on invariants.
Conclusion
Schema validation gives MongoDB collections a contract that every writer must honor, while keeping the flexibility to evolve. Write $jsonSchema validators with bsonType, required, enum, and range and pattern keywords, combine them with $expr for cross-field rules, and roll them out gradually with validationAction: "warn" and validationLevel: "moderate". Use $nor with $jsonSchema to find the documents that don't comply, and catch error code 121 in your app to turn failures into useful messages.
Pick the collection that causes the most "why is this field a string?" bugs, write a validator covering just its required fields and types, and apply it in warn mode today. A week of server logs will tell you exactly which code paths need fixing before you flip it to error.


