Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 011 / 100

Schema validation in MongoDB: what developers should know

Flexible does not mean unvalidated.

Expona Admin8 min read

Schema validation in MongoDB: what developers should know

MongoDB's flexible document model is a powerful feature, but flexibility without guardrails leads to messy data and bugs that surface only in production. Schema validation in MongoDB gives you the best of both worlds: the schema flexibility you need and the data quality guarantees your team expects. In this article, we'll walk through the tools and patterns that let you enforce data contracts on your collections without abandoning MongoDB's core advantages.

Why schema validation matters in a flexible database

When we first adopt MongoDB, the lack of enforced schemas feels liberating. We can add fields to documents on the fly, omit optional data without migration hassle, and iterate quickly. Six months later, we're querying the database and finding that some documents have email, others have Email, and a few have emailAddress. Business logic breaks. Reports fail. The flexibility that once felt like a feature now feels like a liability.

Schema validation solves this by letting you define expectations at the database layer. Unlike rigid relational schemas, MongoDB's validation is optional per collection and tunable. You decide what fields matter, which are required, what types are acceptable, and what happens when data doesn't match. This means you can enforce standards on critical collections like users and orders while keeping experimental collections loose.

$jsonSchema validators on collections

The primary mechanism for schema validation in MongoDB is the $jsonSchema validator. This is a JSON Schema dialect that runs at write time and can reject or warn about invalid documents before they hit your collection.

You attach a validator to a collection using the collMod command. Here's a practical example: validating a customers collection for an e-commerce system.

javascript
db.runCommand({
  collMod: "customers",
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["email", "firstName", "lastName", "createdAt"],
      properties: {
        _id: { bsonType: "objectId" },
        email: {
          bsonType: "string",
          pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
        },
        firstName: { bsonType: "string" },
        lastName: { bsonType: "string" },
        phone: { bsonType: ["string", "null"] },
        status: {
          enum: ["active", "suspended", "deleted"]
        },
        createdAt: { bsonType: "date" },
        metadata: {
          bsonType: "object",
          properties: {
            lastLogin: { bsonType: "date" },
            loginCount: { bsonType: "int" }
          }
        }
      },
      additionalProperties: false
    }
  }
});

This validator enforces several constraints. The required array specifies fields that must exist in every document. The properties object defines types and patterns for each field. Note that phone is optional but when present must be either a string or null. The status field uses enum to restrict values to a known set. Finally, additionalProperties: false rejects any fields not explicitly declared.

When you try to insert a document that violates this schema, MongoDB will reject it by default:

javascript
// This will fail validation
db.customers.insertOne({
  email: "alice@example.com",
  firstName: "Alice",
  // missing lastName and createdAt
  status: "unknown", // not in enum
  createdAt: new Date()
});

// Output:
// MongoServerError: Document failed validation

Required fields and types

Deciding which fields are truly required is one of the most important schema decisions you'll make. A field should be required if your application logic always needs it, not just sometimes.

Let's look at a real scenario from a SaaS system. You're building a feature flag service with a flags collection:

javascript
db.runCommand({
  collMod: "flags",
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["name", "key", "enabled", "projectId", "createdAt", "updatedAt"],
      properties: {
        _id: { bsonType: "objectId" },
        name: { bsonType: "string", minLength: 1, maxLength: 255 },
        key: {
          bsonType: "string",
          pattern: "^[a-z0-9_-]+$",
          minLength: 1,
          maxLength: 100
        },
        projectId: { bsonType: "objectId" },
        description: { bsonType: ["string", "null"] },
        enabled: { bsonType: "bool" },
        rolloutPercentage: {
          bsonType: ["int", "double"],
          minimum: 0,
          maximum: 100
        },
        tags: {
          bsonType: "array",
          items: { bsonType: "string" }
        },
        createdAt: { bsonType: "date" },
        updatedAt: { bsonType: "date" },
        createdBy: { bsonType: "objectId" }
      }
    }
  }
});

Notice that name, key, enabled, and timestamps are required. Fields like description and rolloutPercentage are optional. When you make a field required, your application code must ensure it's always present before writing to the database. If you get this wrong, writes will fail and you'll catch it immediately rather than in production logs weeks later.

Type validation is equally critical. Use bsonType to specify the MongoDB/BSON type. Common types include string, int, double, bool, date, objectId, array, and object. You can also allow multiple types with an array: bsonType: ["string", "null"] permits either a string or null but nothing else.

validationAction: error or warn

MongoDB gives you two ways to handle validation failures: strict enforcement or warnings. The validationAction parameter controls this behavior.

error (the default) will reject any write that violates the schema. This is the right choice for production collections where data integrity is non-negotiable:

javascript
db.runCommand({
  collMod: "orders",
  validator: { $jsonSchema: { /* schema here */ } },
  validationAction: "error"
});

warn will allow the write but log a warning. This is useful when you're rolling out schema validation to an existing collection with dirty data, or when you're uncertain about requirements and want to gather telemetry first:

javascript
db.runCommand({
  collMod: "legacyEvents",
  validator: { $jsonSchema: { /* schema here */ } },
  validationAction: "warn",
  validationLevel: "moderate"
});

There's also validationLevel, which controls when validation runs. strict (default) validates all inserts and updates. moderate validates new inserts but only validates updates if the document already conformed to the schema. This is helpful when migrating legacy data gradually.

In practice, we recommend starting with validationAction: "warn" and validationLevel: "moderate" on existing collections. Run this in staging for a week, monitor the logs for warnings, fix the underlying data issues, then promote to error on production. For new collections, go straight to error and strict.

Validate in application code too

Database validation is a safety net, not a firewall. Your application code should validate data before it reaches the database. There are several reasons why this matters.

First, database validation is a hard stop. If your validation is too strict or your requirements change, you're stuck in a deploy-and-modify cycle. Application validation can be softer: log warnings, offer defaults, or prompt the user to fix the input. Second, database errors are expensive to handle in production. A validation error at the app tier lets you respond gracefully to the user. Third, database-only validation offers no protection against concurrent bugs or race conditions in your code.

Use a validation library in your application stack. If you're using NestJS, the class-validator decorator pattern works well:

javascript
import { IsEmail, IsNotEmpty, IsEnum, Length } from 'class-validator';

class CreateCustomerDto {
  @IsEmail()
  email: string;

  @IsNotEmpty()
  @Length(1, 255)
  firstName: string;

  @IsNotEmpty()
  @Length(1, 255)
  lastName: string;

  @IsEnum(['active', 'suspended', 'deleted'])
  status: string;
}

async createCustomer(dto: CreateCustomerDto) {
  // Application validation has already run via decorators
  // Database validation is the second layer of defense
  const result = await this.customersCollection.insertOne({
    ...dto,
    createdAt: new Date()
  });
  return result;
}

If you're using Node.js with a schema validation library like joi or yup, apply it before every database write:

javascript
const customerSchema = joi.object({
  email: joi.string().email().required(),
  firstName: joi.string().max(255).required(),
  lastName: joi.string().max(255).required(),
  status: joi.string().valid('active', 'suspended', 'deleted').required(),
  phone: joi.string().allow(null).optional()
});

async function createCustomer(data) {
  const { error, value } = customerSchema.validate(data);
  if (error) {
    throw new ValidationError(error.message);
  }
  // Now insert, knowing the data passed app-layer validation
  return await customers.insertOne(value);
}

The goal is defense in depth. Your application validates first, rejects invalid input early, and provides good error messages to users. MongoDB's schema validation catches any bugs that slip through your app logic and prevents corrupt data from persisting. Together, they create a robust data quality system.

Common mistakes

  • Over-validating optional fields: Don't mark a field as required unless your entire application truly depends on its presence. When in doubt, make it optional and handle null or missing values in your code.
  • Tight regex patterns that reject valid input: Email regex is notoriously tricky. Use a permissive pattern or validate emails via a verification step instead of a strict regex in the schema.
  • Forgetting to validate at the application layer: Schema validation without app-layer checks gives a false sense of security. Budget time to implement proper validation in your service logic.
  • Deploying strict validation to a collection with dirty data: Always start with validationAction: "warn" and validationLevel: "moderate"` on existing collections. Fix the data before tightening constraints.
  • Never reviewing or updating schemas: As your system evolves, schemas rot. Schedule quarterly reviews of your validators and update them as requirements change. Treat your schema like code that needs maintenance.

Key takeaways

  • MongoDB's flexibility is powerful but requires discipline. Use $jsonSchema validators on collections where data quality matters most.
  • Define required fields only for data your application always needs. Use bsonType to enforce types, and use string patterns and numeric ranges to narrow the valid value space.
  • Start with validationAction: "warn" and validationLevel: "moderate"` when rolling out validation to existing collections. Migrate to strict enforcement once your data is clean.
  • Never rely on database validation alone. Implement application-layer validation using a library like class-validator, joi, or yup. This catches bugs early and gives users better error messages.
  • Schema validation is not a one-time setup. Review and refine your validators as your system grows and requirements change. Treat your schema as living documentation of your data contracts.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles