Enforcing Required Fields and Types

MongoDB collections don’t require a fixed schema by default — that flexibility is powerful, but it also means nothing stops one document from having an age field as a string and another as a number, or missing a required email field entirely. Once an application matures, that flexibility becomes a liability. MongoDB’s answer is schema validation: rules attached directly to a collection that the server checks on every insert and update, using a syntax called $jsonSchema.

Overview: How Schema Validation Works

Every MongoDB document is stored as BSON (Binary JSON), a binary-encoded superset of JSON with extra types JSON doesn’t have — ObjectId, Date, Decimal128, distinct 32-bit int and 64-bit long integer types, and more. Because collections are schemaless by default, MongoDB will happily store a price as a string in one document and a number in the next, or let a document skip a field entirely. That’s fine for prototyping, but risky once other code, reports, or teammates depend on a predictable shape.

Validation rules are attached to a collection (not to the database, and not to individual fields as a separate object) using the $jsonSchema operator, which follows a subset of the JSON Schema standard. You set it either when you create the collection with createCollection(), or after the fact against an existing collection with the collMod command. The rule set says which fields are required, what bsonType each field must be, and can add constraints like minimum, maximum, pattern (a regex), enum, and minItems for arrays.

Two settings control how strict this becomes. validationAction decides what happens when a write fails validation: "error" (the default) rejects the write outright, while "warn" lets the write through but logs a warning — useful for rolling out rules gradually on a collection with existing messy data. validationLevel decides which writes get checked: "strict" (the default) validates every insert and every update, while "moderate" only validates inserts and updates to documents that already satisfy the schema, leaving legacy non-conforming documents alone until something else touches them.

It’s important to understand what this is not. Unlike a SQL NOT NULL or CHECK constraint enforced by the storage engine itself, MongoDB’s validator is a server-side gate checked at write time on the exact document being written — it does not retroactively scan or fix existing documents when you add or change it. Old documents that violate a brand-new rule are left in place and simply won’t be touched again until you (or the validator) intervene.

Syntax

db.createCollection("collectionName", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["field1", "field2"],
      properties: {
        field1: { bsonType: "string", description: "must be a string and is required" },
        field2: { bsonType: "int", minimum: 0, description: "must be a positive integer and is required" }
      }
    }
  },
  validationLevel: "strict",
  validationAction: "error"
});
  • bsonType — the BSON type required at the object level ("object" for the document root) or per field ("string", "int", "long", "double", "bool", "date", "objectId", "array", "null", and others). Can also be an array of allowed types.
  • required — an array of field names that must be present on every document.
  • properties — an object describing per-field rules; unlisted fields are allowed unless you add additionalProperties: false.
  • validationLevel"strict" (check all writes) or "moderate" (only check already-valid documents).
  • validationAction"error" (reject invalid writes) or "warn" (log only, allow the write).

Examples

First, switch into a working database:

use ecommerce;

Example 1: Requiring fields and types on a new collection

db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["name", "email", "age"],
      properties: {
        name: { bsonType: "string", description: "must be a string and is required" },
        email: {
          bsonType: "string",
          pattern: "^.+@.+\\..+$",
          description: "must be a valid-looking email and is required"
        },
        age: {
          bsonType: ["int", "long"],
          minimum: 0,
          maximum: 120,
          description: "must be an integer between 0 and 120 and is required"
        }
      }
    }
  },
  validationAction: "error"
});

Output:

{ ok: 1 }

Now try inserting a document that’s missing email and age:

db.users.insertOne({ name: "Priya Shah" });

Output:

MongoServerError: Document failed validation
Failing documents:
[{
  operatorName: '$jsonSchema',
  schemaRulesNotSatisfied: [
    {
      operatorName: 'required',
      specifiedAs: { required: [ 'name', 'email', 'age' ] },
      missingProperties: [ 'age', 'email' ]
    }
  ]
}]

The insert is rejected before it ever reaches storage, and the error even names exactly which required fields were missing — extremely useful for debugging failed writes from application code.

Example 2: Adding validation to an existing collection

If orders already exists, you attach a validator with collMod instead of createCollection:

db.runCommand({
  collMod: "orders",
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["customerId", "items", "status"],
      properties: {
        customerId: { bsonType: "objectId", description: "must reference a customer and is required" },
        items: {
          bsonType: "array",
          minItems: 1,
          items: {
            bsonType: "object",
            required: ["sku", "quantity"],
            properties: {
              sku: { bsonType: "string" },
              quantity: { bsonType: "int", minimum: 1 }
            }
          }
        },
        status: { enum: ["pending", "shipped", "delivered", "cancelled"] }
      }
    }
  },
  validationLevel: "moderate",
  validationAction: "warn"
});

Output:

{ ok: 1 }

Here validationLevel: "moderate" means existing orders documents that predate this rule (which might be missing status, for example) are left untouched until they’re next updated. validationAction: "warn" means even a genuinely invalid new write is still accepted, but logged — a safe way to observe how much of your write traffic would break before flipping to "error".

Example 3: Inspecting the active validator

db.getCollectionInfos({ name: "orders" });

Output:

[
  {
    name: 'orders',
    type: 'collection',
    options: {
      validator: { '$jsonSchema': { bsonType: 'object', required: [Array], properties: [Object] } },
      validationLevel: 'moderate',
      validationAction: 'warn'
    },
    info: { readOnly: false }
  }
]

getCollectionInfos() is how you audit which collections have validators and what they currently enforce, without having to remember or re-derive the rules from application code.

How It Works Step by Step

On every insertOne, insertMany, updateOne, updateMany, or findOneAndUpdate, before MongoDB writes anything to the storage engine it checks whether the collection has a validator. If it does, and validationLevel says this write should be checked, the resulting document (after applying the update) is matched against the $jsonSchema rules field by field: presence of every name in required, then each listed field’s bsonType and any extra constraints (minimum, pattern, enum, and so on). If everything matches, the write proceeds normally and is subject to the same durability guarantees as any other write. If something fails, the server either rejects the operation with a DocumentValidationFailure error (validationAction: "error") or lets it through while writing a message to the log (validationAction: "warn"). This check happens per-document and is independent of indexes — it doesn’t use or need an index, since it’s purely inspecting the shape of the document itself.

Common Mistakes

Mistake 1: Assuming plain JS numbers satisfy an "int" requirement. mongosh is a JavaScript shell, and JavaScript has only one numeric type internally — a plain number literal like 25 is sent to the server as a BSON double, not a BSON int.

// Wrong: age will be stored as a double, which fails a strict bsonType: "int" rule
db.users.insertOne({ name: "Arjun Mehta", email: "arjun@example.com", age: 25 });

Either accept both types in the schema, or force the correct BSON type on insert:

// Fix 1: allow either type in the schema
// age: { bsonType: ["int", "double"], minimum: 0 }

// Fix 2: force a real BSON int from the shell
db.users.insertOne({ name: "Arjun Mehta", email: "arjun@example.com", age: NumberInt(25) });

Mistake 2: Flipping straight to validationAction: "error" on a collection with existing bad data. If orders already has documents missing status, adding a strict, error-enforcing validator doesn’t fix them — it just means the next legitimate update to one of those documents (even an unrelated field) is suddenly rejected, breaking application code that used to work.

// Wrong: rolled out with no warning period
db.runCommand({
  collMod: "orders",
  validator: { $jsonSchema: { required: ["status"] } },
  validationAction: "error"
});
// Better: warn first, monitor logs, then switch to error once traffic is clean
db.runCommand({
  collMod: "orders",
  validator: { $jsonSchema: { required: ["status"] } },
  validationAction: "warn"
});

Mistake 3: Comparing a stored ObjectId field to a plain string. If customerId is validated as bsonType: "objectId", inserting { customerId: "64f1a2b3c4d5e6f7a8b9c0d1" } (a string, likely read straight from a URL param) fails validation even though it looks correct. Wrap it: new ObjectId("64f1a2b3c4d5e6f7a8b9c0d1").

Best Practices

  • Start new collections with a validator from day one via createCollection() — it’s far easier than retrofitting one onto a collection full of inconsistent documents.
  • When adding validation to an existing collection, start with validationAction: "warn", watch the logs for a while, clean up or migrate offending documents, then switch to "error".
  • Add a short description to each property rule — it’s echoed back in the error message and saves a lot of debugging time.
  • Prefer arrays of acceptable bsonTypes (like ["int", "long", "double"]) for numeric fields unless you truly control every write path and can guarantee the exact BSON type.
  • Use enum for fields with a fixed, small set of valid values (order status, user role) instead of a free-form string plus application-level checks.
  • Re-run db.getCollectionInfos() after any collMod to confirm the rule you intended is the rule that actually landed.
  • Remember validation is enforced by the server on writes, not by drivers or Mongoose in isolation — it’s a real safety net even if application-level validation is bypassed or buggy.

Practice Exercises

  • Create a products collection whose validator requires name (string), price (a non-negative number), and category (one of "electronics", "clothing", "grocery"). Try inserting a document with a negative price and confirm it’s rejected.
  • Take the orders validator from Example 2 and change validationAction to "error", then attempt an update that would remove the last item from an order’s items array (violating minItems: 1). Confirm the update fails.
  • Given a collection with 500 existing documents where 40 are missing an email field, design a validator rollout plan using validationLevel and validationAction that avoids breaking existing application traffic on day one.

Summary

  • $jsonSchema validators enforce required fields and BSON types at the collection level, checked by the server on every write that qualifies.
  • Attach a validator at creation time with createCollection(), or retrofit one onto an existing collection with the collMod command.
  • validationLevel (strict vs moderate) controls which documents get checked; validationAction (error vs warn) controls what happens on failure.
  • Validation does not retroactively fix or scan existing documents — only new inserts and updates that qualify under the current level are checked.
  • Plain JS numbers from mongosh are BSON doubles, not BSON ints — account for this in numeric type rules or use NumberInt()/NumberLong().
  • This is a safety net, not a replacement for embedding-vs-referencing design or application-level checks — use it alongside good schema design, not instead of it.