Ch. 12 · MongoDB

MongoDB Schema Validation and Flexible Records

How to add $jsonSchema validation to a MongoDB collection, roll it out safely with moderate and warn modes, and still allow flexible fields.

~5 min readbeginnerupdated Oct 7, 2026

MongoDB is often called schemaless. A better description is that the schema lives wherever you put it. By default it’s only in application code, which works until a second service, a migration script or a hurried manual fix writes price: "19.99" as a string. Then sums break and sorts go strange. Schema validation moves the rules you actually care about into the database, so every writer has to follow them.

Examples are mongosh for MongoDB 7 and 8. They’re illustrative; no live server was available while writing.

A validator on a new collection

db.createCollection("products", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["sku", "name", "price", "status"],
      properties: {
        sku:    { bsonType: "string", pattern: "^[A-Z0-9-]{4,20}$" },
        name:   { bsonType: "string", minLength: 1 },
        price:  { bsonType: "decimal", minimum: 0 },
        status: { enum: ["draft", "active", "retired"] },
        tags:   { bsonType: "array", items: { bsonType: "string" }, maxItems: 20 }
      }
    }
  }
})
JavaScript

A few choices here worth explaining:

  • bsonType rather than type. MongoDB’s $jsonSchema supports BSON types such as decimal, date, objectId and long, which plain JSON Schema doesn’t have. Money as decimal (Decimal128) avoids floating-point rounding.
  • required lists only fields every product must have. tags is optional, but if present it must be an array of strings.
  • There’s no additionalProperties: false, so products can carry extra fields (color, weightGrams, category-specific attributes). That’s the flexible part.

Now a bad insert:

db.products.insertOne({ sku: "TR-300", name: "Trail Runner", price: "89.00", status: "active" })
JavaScript
MongoServerError: Document failed validation
Additional information: {
  failingDocumentId: ...,
  details: { operatorName: '$jsonSchema', schemaRulesNotSatisfied: [
    { operatorName: 'properties', propertiesNotSatisfied: [
      { propertyName: 'price', details: [ { operatorName: 'bsonType',
        specifiedAs: { bsonType: 'decimal' }, reason: 'type did not match', consideredValue: '89.00', consideredType: 'string' } ] } ] } ] }
}
Text

The error code is 121 (DocumentValidationFailure), and since MongoDB 5.0 the details tell you exactly which rule failed, which makes debugging far easier than it used to be. The fix on the client is price: NumberDecimal("89.00").

When does validation run?

Validation runs on inserts, updates and replacements. On an update, MongoDB validates the document as it would look after the update. So $set: { price: -5 } fails the minimum: 0 rule, and $unset: { name: "" } fails required.

It doesn’t run on reads, and it doesn’t scan existing documents when you add a validator. That second point is important when you add validation to a collection that’s been in production for a while.

Adding validation to existing data

Suppose products already has 2 million documents, and some unknown number have string prices. If you add a strict validator, every update to one of those old documents will fail, even an unrelated one like changing status, because the post-update document still has a string price. That can break a production flow instantly.

Two settings help:

  • validationLevel: "moderate" applies rules to inserts and to updates of documents that are already valid. Updates to documents that were invalid before are allowed through. The default is "strict", which validates every insert and update.
  • validationAction: "warn" logs violations instead of rejecting the write. The default is "error".

A safe rollout looks like this:

// 1. find out how bad it is
db.products.countDocuments({ $nor: [ { $jsonSchema: productSchema } ] })

// 2. turn validation on in warn mode, watch the logs
db.runCommand({ collMod: "products", validator: { $jsonSchema: productSchema },
                validationLevel: "moderate", validationAction: "warn" })

// 3. fix the data in batches, e.g. convert string prices
db.products.updateMany({ price: { $type: "string" } },
  [ { $set: { price: { $toDecimal: "$price" } } } ])

// 4. tighten
db.runCommand({ collMod: "products", validationLevel: "strict", validationAction: "error" })
JavaScript

The $nor with $jsonSchema query in step 1 is a neat trick: $jsonSchema works as a normal query operator, so you can find the documents that would fail before you enforce anything.

Keeping flexibility on purpose

The point isn’t to turn MongoDB into a rigid table. Validate what your code relies on: identifiers, types of numeric and date fields used in queries, enum values that drive logic, required fields. Leave descriptive or category-specific attributes open. Some patterns:

  • Use oneOf or anyOf for genuinely polymorphic documents, such as a payments collection where card payments need last4 and bank transfers need iban, keyed by a method field.
  • Put open-ended attributes under one subdocument (attributes: { ... }) and validate only that it’s an object. That keeps the top level tidy.
  • Set additionalProperties: false only when you’re sure; it also forces you to list _id, which surprises people the first time a perfectly normal insert fails.

Checking what’s currently enforced

Validators are easy to forget once they’re in place, and a new developer may not know one exists until a write fails. You can read the current rules back:

db.getCollectionInfos({ name: "products" })[0].options
// { validator: { $jsonSchema: { ... } }, validationLevel: 'strict', validationAction: 'error' }
JavaScript

Keep the schema itself in version control, next to the migration that applies it with collMod, so changes are reviewed like code. Treat loosening a rule (adding an enum value, making a field optional) as safe to deploy before the code that uses it, and tightening a rule as something to deploy after every writer has been updated and the data backfilled.

Mistakes and limits

  • Validation isn’t a uniqueness check. “Each SKU must be unique” needs a unique index, not a schema rule.
  • It can be bypassed. Users with the bypassDocumentValidation privilege, and operations that set that option, skip validation. Restore and some migration tools use it.
  • It isn’t a replacement for app-side validation. Users need friendly field-level error messages, which you produce in the application. The database validator is the backstop that catches every other writer.
  • Cross-document rules don’t belong here. A validator sees one document. “Order total equals the sum of payments in another collection” can’t be checked this way.

Interview practice

Your team owns a users collection written by three services. Analytics reports that createdAt is sometimes a string, sometimes a date, and sometimes missing. You’re asked to “add schema validation” without breaking any service. Walk through your plan.

Answer

I’d start by measuring: count documents where createdAt is a date, a string and missing, using $type in a $group, so I know how much data needs fixing and roughly which service wrote it (other fields or a source field usually reveal that).

Next I’d add a validator requiring createdAt with bsonType: "date", but with validationLevel: "moderate" and validationAction: "warn". Nothing gets rejected; the server logs every write that would fail. Over a few days those log entries identify which service still writes strings or omits the field, and the team fixes that service.

While that happens, I’d backfill in batches: convert strings with $dateFromString in an update pipeline and set a sensible value for missing ones (perhaps derived from the _id timestamp with $toDate: "$_id", which is accurate to the second for default ObjectIds). Once logs show no new violations and the backfill is complete, I’d switch to strict and error. Then any new bad write fails immediately with code 121, which surfaces the bug at the writer instead of in a report weeks later.

Further reading

More in MongoDB

read ✓MongoDB · mid

MongoDB Bulk Writes

Batch a nightly upsert job with bulkWrite, pick ordered or unordered mode, and read writeErrors so partial failures don't get lost or retried.

~6 min readread →
read ✓MongoDB · mid

MongoDB Capped Collections

Size a capped collection for a recent-errors feed, follow it with a tailable cursor, and see why TTL indexes suit time-based retention better.

~6 min readread →
read ✓MongoDB · hard

MongoDB Change Streams and Resume Tokens

Build an order-to-search sync on change streams, save resume tokens after processing, and recover when the oplog has rolled past you.

~6 min readread →
esc