Type something to search...
Schema Validation in MongoDB with JSON Schema

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.

bsonTypeStoresNotes
stringUTF-8 text
int32-bit integerNumberInt() in mongosh
long64-bit integerNumberLong()
double64-bit floating pointDefault for JavaScript numbers
decimalDecimal128Use for money
numberAny of int, long, double, decimalHandy alias
booltrue or falseNote: bool, not boolean
dateBSON date
objectIdObjectId
objectEmbedded document
arrayArray
nullNull

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

validationLevelInserts checked?Updates to valid docs checked?Updates to invalid docs checked?
strict (default)YesYesYes
moderateYesYesNo
offNoNoNo

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

validationActionBehavior on invalid write
error (default)Rejects the write with a validation error
warnAllows 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:

  1. Add the validator with validationAction: "warn".
  2. Review warnings and fix the application code that produces invalid writes.
  3. Find and fix existing invalid documents (next section).
  4. Switch to validationAction: "error" with validationLevel: "moderate".
  5. 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:

ConcernApplication (Mongoose, Zod, Pydantic)Database ($jsonSchema)
Friendly, localized error messagesStrongWeak
Defaults, type coercion, transformationsYesNo
Validation against other data or external APIsYesNo
Applies to every writer (scripts, other services)NoYes
Protects against bugs in your own validationNoYes

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.

Tags :
Share :

Related Posts

A Complete Guide to MongoDB Query Operators

A Complete Guide to MongoDB Query Operators

Your first MongoDB queries are usually simple equality filters: find the user with this email, find orders with this status. That covers a surprising

Continue Reading
Async MongoDB in Python with Motor and FastAPI

Async MongoDB in Python with Motor and FastAPI

FastAPI runs your endpoints on an event loop. That's what lets a single worker juggle hundreds of concurrent requests: while one request waits on the

Continue Reading
Atlas Online Archive: Tiering Cold Data to Cut Costs

Atlas Online Archive: Tiering Cold Data to Cut Costs

Look at almost any production database and you'll find the same shape. A small slice of recent data gets nearly all the reads and writes: this week's

Continue Reading