16 Topics
Chapter•180 Minutes

MongoDB Data Modeling and Schema Design

Learn to design MongoDB schemas around application access patterns, choose between embedding and referencing, model relationships and polymorphic data, enforce data quality, and apply production-ready schema patterns with measurable trade-offs.

Why Data Modeling Comes Before Collection Design

Start with application questions, not tables

MongoDB data modeling is the process of deciding which facts belong together in a document, which documents belong in a collection, how relationships are represented, and which operations the schema should make efficient.

A relational design often begins by identifying entities and normalizing them into tables. MongoDB supports that style when it fits, but its document model also lets you store data that is commonly read together in a single document.

The key design question is not simply “What entities exist?” It is “How will the application read, write, update, and retain these facts?”

Example application requirements

Imagine an online store. The application must:

  1. Display a product page with name, price, images, and category.
  2. Display an order with the purchased product names and prices as they were at checkout.
  3. Show a customer's recent orders.
  4. Update inventory safely when an order is placed.
  5. Let an administrator update a product description without rewriting historical orders.

These requirements imply different modeling choices. Product information can live in a products collection, order history in orders, and customer information in customers. An order can embed a snapshot of each purchased item so later product edits do not rewrite history.

Build an access-pattern inventory

Before creating collections, write down the operations and their frequency:

  • Query: which fields identify the records?
  • Read shape: which related fields are needed together?
  • Cardinality: how many related records can exist?
  • Update behavior: do related values change together?
  • Growth: can an array grow without a known limit?
  • Consistency: must multiple records change atomically?
  • Retention: are historical values immutable or legally retained?

A schema that makes the most frequent, latency-sensitive reads simple can be better than one that minimizes every duplicate value. However, denormalization has costs: duplicated values must be maintained, documents can grow, and writes can become more complex.

Specifications & Requirements
  • Access pattern:A concrete read or write the application performs.
  • Cardinality:The number of related records, such as one-to-one, one-to-many, or many-to-many.
  • Denormalization:Intentionally duplicating data to simplify reads or preserve a snapshot.
Note

Do not copy a relational schema into MongoDB without examining the application's real query patterns. Model the data around the operations that must be correct and fast.

Translate application requirements into representative queries
// Product page: one product by its stable identifier
db.products.findOne(
  { _id: ObjectId("65a000000000000000000001") },
  { name: 1, price: 1, images: 1, categoryId: 1 }
);

// Customer order history: latest orders first
db.orders.find(
  { customerId: ObjectId("65a000000000000000000010") },
  { orderNumber: 1, status: 1, total: 1, createdAt: 1 }
).sort({ createdAt: -1 }).limit(20);

// Inventory lookup for checkout
db.inventory.findOne(
  { productId: ObjectId("65a000000000000000000001") },
  { available: 1, reserved: 1 }
);
Key Takeaways
  • Start from real queries and updates.
  • Include cardinality, growth, consistency, and retention in the design.
  • Accept duplication only when its read or historical-value benefit justifies its maintenance cost.
Knowledge Check

What is the strongest starting point for a MongoDB schema design?

Documents, Collections, and the Aggregate Boundary

Choose the unit that should be read and changed together

A BSON document is a natural unit for storing a coherent set of fields. A collection groups documents that are queried or managed together. In many designs, a document acts as an aggregate boundary: the data inside it can be read and updated together, and a single-document write is atomic.

For example, a shipping address used only as part of a customer profile may be embedded in the customer document. An order line may be embedded in an order because its quantity, unit price, and selected product description form part of that order's history.

By contrast, inventory shared by many orders is a poor candidate for being duplicated as an independently mutable quantity in every order. Inventory is a separate resource with its own concurrency rules.

Example: customer profile

{
  _id: ObjectId("65a000000000000000000010"),
  email: "asha@example.com",
  name: {
    first: "Asha",
    last: "Rao"
  },
  addresses: [
    {
      label: "home",
      line1: "12 Lake Road",
      city: "Bengaluru",
      postalCode: "560001",
      isDefault: true
    }
  ],
  createdAt: ISODate("2026-01-10T08:00:00Z")
}

This structure works if addresses are part of the profile, are usually fetched with it, and remain within a reasonable size. If addresses become independently permissioned, audited, searched, or unbounded, a separate collection may be more appropriate.

Atomicity boundary

MongoDB guarantees atomicity for an individual document write. If two documents must change as one indivisible business operation, consider whether the model can bring the fields into one document. If not, use a transaction when the operation truly requires multi-document atomicity, and design retry and failure behavior carefully.

Do not enlarge a document merely to avoid transactions. A document boundary should reflect the domain and access patterns, not just an attempt to make every operation single-document.

Specifications & Requirements
  • Document atomicity:A single-document write is atomic.
  • Aggregate boundary:The unit of data that is commonly read and changed together.
Note

Single-document atomicity does not mean every business workflow should be forced into one document. Shared, independently changing, or unbounded data often needs its own collection.

Update related fields atomically within one customer document
db.customers.updateOne(
  { _id: ObjectId("65a000000000000000000010") },
  {
    $set: {
      "name.first": "Asha",
      updatedAt: new Date()
    }
  }
);
Key Takeaways
  • Group fields that have a strong lifecycle and are commonly accessed together.
  • Keep independently changing shared resources separate when duplication would create consistency problems.
  • Use transactions only when cross-document atomicity is required by the business operation.
Knowledge Check

Which data is usually a strong candidate to embed in an order?

Embedding: Benefits, Limits, and Safe Array Design

When embedding is a good fit

Embedding places related values inside the parent document as nested documents or arrays. It is especially useful when:

  • Child data is usually read with the parent.
  • The child has no meaningful independent lifecycle.
  • The number of children is bounded or naturally small.
  • The parent and child should be updated atomically.
  • A snapshot is needed even if the source record changes later.

Embedded one-to-one data

{
  _id: ObjectId("65a000000000000000000020"),
  username: "asha",
  profile: {
    displayName: "Asha Rao",
    locale: "en-IN",
    theme: "dark"
  }
}

Embedded one-to-few data

{
  _id: ObjectId("65a000000000000000000021"),
  title: "MongoDB Notes",
  tags: ["database", "mongodb", "backend"],
  settings: {
    visibility: "private",
    allowComments: false
  }
}

Array growth is a design constraint

An array is not automatically wrong, but an unbounded array is dangerous. For example, storing every click event, notification, or transaction inside a user's document causes the document to grow continuously. This can increase document rewrite work, create contention, complicate pagination, and eventually hit MongoDB's 16 MiB maximum BSON document size.

Avoid using an ever-growing array as a substitute for a queryable history collection. If the parent only needs the latest few items, keep a bounded subset or use a separate collection for the full history.

Bounded recent-items pattern

db.users.updateOne(
  { _id: userId },
  {
    $push: {
      recentItems: {
        $each: [{ itemId, viewedAt: new Date() }],
        $sort: { viewedAt: -1 },
        $slice: 10
      }
    }
  }
);

This keeps only the ten most recent entries in recentItems. It is appropriate only if older entries are intentionally disposable or are stored elsewhere.

Nested document updates

Use dot notation to update a nested field without replacing the entire subdocument:

db.customers.updateOne(
  { _id: customerId },
  { $set: { "preferences.theme": "dark" } }
);

Setting preferences to a new object instead may replace the existing subdocument and remove keys that are not included in the replacement.

Specifications & Requirements
  • Embedding:Store related data inside the parent document.
  • Bounded array:An array whose maximum size is intentionally constrained.
  • Dot notation:Target a nested field without replacing the full nested object.
Note

MongoDB documents have a 16 MiB BSON size limit. More importantly, arrays with unlimited growth can become operationally expensive long before reaching that limit.

Bound an embedded array and update a nested field
// Retain only the five latest status events in this compact summary
db.jobs.updateOne(
  { _id: jobId },
  {
    $push: {
      recentStatusEvents: {
        $each: [{ status: "running", at: new Date() }],
        $sort: { at: -1 },
        $slice: 5
      }
    },
    $set: { "settings.notifications": true }
  }
);
Key Takeaways
  • Embed small, bounded, co-accessed data.
  • Use $slice with $push to maintain intentionally bounded recent-item lists.
  • Store complete histories separately when they can grow without limit.
Knowledge Check

Which design is most appropriate for a user's complete, potentially millions-of-events activity history?

Referencing: ObjectIds, Cardinality, and Lifecycle

Store relationships with identifiers

Referencing stores a related document's identifier rather than embedding all of its fields. In MongoDB, a reference is often an ObjectId, but it can also be another stable key, such as a UUID or a business identifier.

References are useful when related data:

  • Is large or unbounded.
  • Is shared by many parent documents.
  • Has an independent lifecycle or permissions.
  • Is frequently updated and should not be duplicated everywhere.
  • Must be queried independently.
  • Would make parent documents too large or frequently rewritten.

One-to-many example

A blog post may have many comments. If comments can grow indefinitely and need pagination, keep them in a separate collection:

// posts
{
  _id: ObjectId("65a000000000000000000030"),
  title: "Designing a MongoDB Schema",
  authorId: ObjectId("65a000000000000000000010"),
  publishedAt: ISODate("2026-02-01T10:00:00Z")
}

// comments
{
  _id: ObjectId("65a000000000000000000031"),
  postId: ObjectId("65a000000000000000000030"),
  authorId: ObjectId("65a000000000000000000011"),
  body: "The array-growth discussion helped.",
  createdAt: ISODate("2026-02-01T11:00:00Z")
}

Create an index for the common access pattern:

db.comments.createIndex({ postId: 1, createdAt: -1 });

Then retrieve a page of comments:

db.comments.find({ postId })
  .sort({ createdAt: -1, _id: -1 })
  .limit(20);

For stable pagination at scale, use a cursor based on the last seen sort values rather than relying on a large skip.

References are not automatically enforced foreign keys

A field named authorId does not make MongoDB automatically guarantee that the referenced author exists. Your application, schema design, transactions, and validation strategy must maintain referential integrity where required. Consider deletion semantics explicitly: restrict deletion, cascade with a controlled workflow, soft-delete, or preserve historical snapshots.

$lookup and application-side reads

Use $lookup when joining collections is appropriate for the query and supported by indexes and workload. Alternatively, perform two targeted queries in the application. Avoid assuming either approach is universally faster: measure realistic data volume, result size, indexes, and latency.

Specifications & Requirements
  • Reference:A field containing the identifier of another document.
  • $lookup:An aggregation stage that combines documents from another collection.
  • Referential integrity:Rules that keep relationships valid as records change.
Note

A reference is a relationship convention, not a foreign-key constraint. Define what happens when referenced records are missing, changed, or deleted.

Join comments with their post when a combined result is needed
db.comments.aggregate([
  { $match: { postId: ObjectId("65a000000000000000000030") } },
  { $sort: { createdAt: -1, _id: -1 } },
  { $limit: 20 },
  {
    $lookup: {
      from: "posts",
      localField: "postId",
      foreignField: "_id",
      as: "post"
    }
  },
  { $unwind: "$post" },
  {
    $project: {
      body: 1,
      createdAt: 1,
      "post.title": 1
    }
  }
]);
Key Takeaways
  • Use references for shared, independently managed, or unbounded data.
  • Index the foreign key and sort fields used by common child queries.
  • Plan deletion and missing-reference behavior explicitly.
Knowledge Check

Why are comments often stored separately from a post?

One-to-One, One-to-Many, and Many-to-Many Relationships

Model the relationship, not just the entities

One-to-one

If two pieces of information are almost always read together and share a lifecycle, embed one in the other. If the related data is sensitive, large, optional, independently permissioned, or accessed through a separate workflow, use a separate collection and a unique reference.

Example: a user may have an optional securitySettings document. If it is stored separately, enforce one settings document per user with a unique index.

db.securitySettings.createIndex({ userId: 1 }, { unique: true });

One-to-many

For a small bounded set, embed an array. For an unbounded set, store child documents separately with a parent reference. The “many” side often carries the reference because each child belongs to one parent.

Many-to-many

A course can have many students, and a student can enroll in many courses. A dedicated enrollment collection models the relationship and its own attributes:

// enrollments
{
  _id: ObjectId("65a000000000000000000040"),
  studentId: ObjectId("65a000000000000000000010"),
  courseId: ObjectId("65a000000000000000000041"),
  enrolledAt: ISODate("2026-03-01T09:00:00Z"),
  status: "active",
  progressPercent: 35
}

The relationship is a first-class entity because it contains enrolledAt, status, and progressPercent, not just two identifiers.

db.enrollments.createIndex(
  { studentId: 1, courseId: 1 },
  { unique: true }
);

db.enrollments.createIndex({ courseId: 1, status: 1 });

The compound unique index prevents duplicate enrollments for the same student-course pair. Its field order supports queries beginning with studentId; the second index supports course-based status filtering.

Avoid giant inverse arrays

It may seem convenient to store all course IDs in a student's document and all student IDs in a course document. That can duplicate relationship state and create unbounded arrays. If the relationship itself has attributes or can become large, a join collection is usually easier to manage.

Relationship lifecycle questions

For every relationship, decide:

  • Can the child exist without the parent?
  • Can a parent have a very large number of children?
  • Must duplicate relationships be prevented?
  • Does the relationship have its own fields?
  • What should happen on deletion or archival?
  • Which direction is queried most often?
Specifications & Requirements
  • One-to-one:One record corresponds to at most one related record.
  • One-to-many:One parent has multiple related children.
  • Many-to-many:Records on both sides can relate to many records on the other side.
Note

When a relationship has its own fields, lifecycle, or uniqueness rules, model it as a first-class collection rather than hiding it inside two potentially unbounded arrays.

Query enrollment history and course roster
// A student's enrollments
db.enrollments.find({ studentId })
  .sort({ enrolledAt: -1 });

// Active students in a course
db.enrollments.find({
  courseId,
  status: "active"
});

// The unique compound index prevents duplicate pairs
db.enrollments.createIndex(
  { studentId: 1, courseId: 1 },
  { unique: true }
);
Key Takeaways
  • Embed bounded relationships when they are read and changed together.
  • Use a child collection for large one-to-many relationships.
  • Use a relationship collection when many-to-many links have attributes or uniqueness requirements.
Knowledge Check

A student-course enrollment stores progress and enrollment status. Which model is most appropriate?

Normalization, Denormalization, and Consistency Trade-offs

Duplication is a deliberate trade-off

Normalization reduces duplicated facts and can make updates easier to reason about. Denormalization duplicates selected values to reduce reads, simplify response assembly, or preserve a point-in-time snapshot.

Neither is always best. MongoDB supports both, and production schemas often combine them.

Example: product reference versus order snapshot

A product document represents the current catalog value:

{
  _id: ObjectId("65a000000000000000000001"),
  sku: "KB-104",
  name: "Mechanical Keyboard",
  currentPrice: 4999,
  active: true
}

An order should usually preserve the value charged at checkout:

{
  _id: ObjectId("65a000000000000000000050"),
  customerId: ObjectId("65a000000000000000000010"),
  items: [
    {
      productId: ObjectId("65a000000000000000000001"),
      sku: "KB-104",
      nameAtPurchase: "Mechanical Keyboard",
      unitPriceAtPurchase: 4599,
      quantity: 1
    }
  ],
  currency: "INR",
  total: 4599,
  status: "placed"
}

The order stores productId for navigation and also stores historical values. If the catalog price later changes to 5,499, the old order still reflects the amount agreed at checkout.

Three common duplication strategies

  1. Snapshot duplication: preserve a value as it existed at a business event, such as purchase-time price or shipping address.
  2. Read optimization: duplicate a frequently displayed field, such as an author's display name in a feed item.
  3. Computed duplication: maintain a summary such as order total or comment count to avoid repeatedly computing it.

Keeping duplicated values consistent

Choose a clear update strategy:

  • Update all copies synchronously in a transaction when the business operation requires it.
  • Update copies asynchronously with an event-driven workflow and accept eventual consistency.
  • Treat the duplicate as a historical snapshot that must not be updated.
  • Recompute derived values periodically or on demand.
  • Avoid duplication if the synchronization cost outweighs the read benefit.

Document the source of truth for every duplicated field. For example, products.currentPrice may be the current catalog price, while orders.items.unitPriceAtPurchase is the immutable purchase price.

Avoid ambiguous field names

Names such as price, name, and status may be ambiguous when duplicated. Prefer semantic names like currentPrice, unitPriceAtPurchase, nameAtPurchase, and statusAtPlacement when they communicate different lifecycle meanings.

Specifications & Requirements
  • Normalization:Reduce duplication and separate independently managed facts.
  • Denormalization:Duplicate selected facts for a deliberate read, snapshot, or computation benefit.
  • Eventual consistency:Copies may temporarily differ but converge after updates are processed.
Note

For every duplicated field, identify its source of truth and whether it is a live copy, an eventually consistent summary, or an immutable historical snapshot.

Compute a purchase total from immutable order-line snapshots
const order = {
  currency: "INR",
  items: [
    { sku: "KB-104", unitPriceAtPurchase: 4599, quantity: 1 },
    { sku: "MS-208", unitPriceAtPurchase: 799, quantity: 2 }
  ]
};

order.subtotal = order.items.reduce(
  (sum, item) => sum + item.unitPriceAtPurchase * item.quantity,
  0
);

// 4599 + (799 * 2) = 6197
Key Takeaways
  • Duplicate data only for a clear reason.
  • Keep historical snapshots distinct from current values.
  • Define consistency, update ownership, and repair behavior for denormalized fields.
Knowledge Check

Why should an order usually store the unit price at purchase time?

Schema Design for High-Volume and Unbounded Data

Model growth intentionally

A schema that works for ten records may fail at ten million if it assumes bounded data, ignores access patterns, or creates excessive contention.

Separate event history from current state

For a user activity system, keep the current profile separate from activity events:

// userActivity
{
  _id: ObjectId("65a000000000000000000060"),
  userId: ObjectId("65a000000000000000000010"),
  eventType: "course_completed",
  resourceId: "mongodb-indexes",
  occurredAt: ISODate("2026-05-01T12:00:00Z"),
  metadata: {
    durationSeconds: 1800
  }
}

Useful index for a user's recent activity:

db.userActivity.createIndex({ userId: 1, occurredAt: -1, _id: -1 });

If the application needs global analytics by event type and date, that is a different access pattern and may need another index or an analytics-oriented data pipeline. Avoid adding every conceivable index because indexes consume storage and increase write cost.

Bucket pattern

For high-volume time-series-like events, a bucket document can group a bounded number of measurements by entity and time window. Bucketing reduces document count and can improve range-oriented reads, but it requires careful handling of bucket capacity, concurrent writers, and queries. MongoDB time series collections may be preferable for true time-series workloads.

A simplified bucket document:

{
  sensorId: "sensor-17",
  bucketStart: ISODate("2026-05-01T10:00:00Z"),
  readings: [
    { at: ISODate("2026-05-01T10:00:01Z"), temperature: 21.2 },
    { at: ISODate("2026-05-01T10:00:05Z"), temperature: 21.3 }
  ],
  count: 2
}

Do not allow readings to grow forever. Use a bounded bucket window or maximum reading count.

Subset pattern

If a product has thousands of reviews but the product page needs only the latest three, store the complete reviews separately and maintain a small subset on the product document. This can reduce the cost of the common product-page read while preserving the full review collection.

Computed pattern

If a dashboard repeatedly needs an expensive aggregate, maintain a derived value or summary collection. Define when it is updated, whether stale values are acceptable, and how it can be rebuilt from the source data.

Outlier pattern

If a small number of records have extremely large arrays or relationships compared with the typical record, isolate exceptional values rather than designing every document around the worst case. For example, a post with millions of followers should not necessarily store every follower ID in one document.

Polymorphic pattern

Documents in one collection can have different optional fields when they share common query behavior. Include a discriminator such as type when it helps interpret the shape. If variants require very different indexes, validation, security, or access patterns, separate collections may be clearer.

Specifications & Requirements
  • Bucket pattern:Group bounded sets of similar measurements or events.
  • Subset pattern:Keep a small, frequently used subset near the parent while storing the full data separately.
  • Computed pattern:Persist derived results to avoid repeating expensive calculations.
  • Outlier pattern:Handle exceptional large records without burdening all typical records.
Note

Common patterns such as bucket, subset, computed, and outlier patterns solve different scaling problems. Pick a pattern only after identifying the workload it improves and the consistency costs it introduces.

Maintain a small latest-review subset while retaining full reviews
// Full review is inserted into the reviews collection separately.
// Keep only the three latest reviews in the product's display subset.
db.products.updateOne(
  { _id: productId },
  {
    $push: {
      latestReviews: {
        $each: [{
          reviewId,
          rating: 5,
          excerpt: "Comfortable and well built",
          createdAt: new Date()
        }],
        $sort: { createdAt: -1 },
        $slice: 3
      }
    }
  }
);
Key Takeaways
  • Separate unbounded event history from current entity state.
  • Bound bucket and embedded subset sizes.
  • Account for refresh strategy and consistency when storing derived values.
Knowledge Check

A product page needs the three latest reviews, but the system must retain every review. Which pattern can help?

Polymorphic Documents and Schema Evolution

One collection can hold related variants

A polymorphic collection stores multiple document shapes that share a common identity or query path. This can be useful for products with different attributes, payment methods, notifications, or content types.

// Physical product
{
  _id: ObjectId("65a000000000000000000070"),
  type: "physical",
  name: "Keyboard",
  price: 4599,
  shippingWeightGrams: 850
}

// Digital product
{
  _id: ObjectId("65a000000000000000000071"),
  type: "digital",
  name: "MongoDB Course",
  price: 1299,
  downloadUrl: "https://example.invalid/download/asset-71"
}

The type field identifies the variant. Shared queries can target fields such as name and price; type-specific queries can filter by type.

When polymorphism is appropriate

Use it when variants share enough behavior and query patterns that one collection is manageable. Prefer separate collections when variants have fundamentally different security requirements, retention, write patterns, validation rules, or indexes.

Schema evolution without breaking old documents

Applications evolve. A field may be added, renamed, or replaced. Avoid assuming every existing document changes immediately when a new application version deploys.

A safe migration often follows an expand-migrate-contract sequence:

  1. Expand: make the application tolerate both old and new shapes; begin writing the new field.
  2. Migrate: backfill old documents in controlled batches.
  3. Verify: measure how many documents still use the old shape and validate data.
  4. Contract: remove old-field support only after the migration is complete and all deployed readers are compatible.

For example, migrate fullName to name without assuming the backfill has finished:

db.users.updateMany(
  { name: { $exists: false }, fullName: { $type: "string" } },
  [
    {
      $set: {
        name: "$fullName",
        schemaVersion: 2
      }
    }
  ]
);

For a large production collection, prefer batched migration and monitor replication lag, write load, errors, and completion. A single updateMany can be suitable for small collections but may be too disruptive for very large datasets.

Version fields

A field such as schemaVersion can help migrations and debugging, but it is not mandatory for every collection. Use it when the application genuinely needs to distinguish incompatible shapes or migration states. Do not add a version number without a policy for what each version means.

Specifications & Requirements
  • Polymorphic collection:A collection containing related document variants.
  • Expand-migrate-contract:A staged rollout that keeps old and new application versions compatible during migration.
  • schemaVersion:An optional field identifying the schema shape or migration version.
Note

Schema migrations must account for old application instances, retries, partial progress, and rollback. Make readers tolerant before changing the stored shape.

Query shared fields and type-specific fields
// All products use common fields
db.products.find(
  { active: true },
  { name: 1, price: 1, type: 1 }
);

// Only physical products have shipping weight
db.products.find({
  type: "physical",
  shippingWeightGrams: { $gt: 1000 }
});
Key Takeaways
  • Use a discriminator when related document variants share common query behavior.
  • Use staged migrations for large or continuously accessed collections.
  • Add schema versioning only when it serves a clear compatibility or migration need.
Knowledge Check

Why should a schema migration often use an expand-migrate-contract sequence?

Schema Validation with JSON Schema

Enforce data shape at the database boundary

Application validation is essential, but multiple services, scripts, migrations, or administrative tools may write to the same collection. MongoDB collection validation can reject documents that do not satisfy defined rules.

MongoDB supports JSON Schema validation with BSON-aware types. Use it to require fields, restrict types, enumerate allowed values, and define nested object shapes.

Example validator

db.createCollection("products", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["sku", "name", "price", "active"],
      properties: {
        sku: {
          bsonType: "string",
          description: "Stable stock keeping unit"
        },
        name: {
          bsonType: "string",
          minLength: 1
        },
        price: {
          bsonType: ["int", "long", "double", "decimal"],
          minimum: 0
        },
        active: {
          bsonType: "bool"
        },
        tags: {
          bsonType: "array",
          items: { bsonType: "string" }
        }
      }
    }
  },
  validationLevel: "strict",
  validationAction: "error"
});

For money, consider storing values as Decimal128 or integer minor units rather than binary floating-point values, depending on the application's currency and arithmetic rules. Choose one representation consistently and validate it.

Validation levels and actions

  • validationLevel: "strict" applies validation to inserts and updates of documents.
  • validationLevel: "moderate" applies validation to inserts and updates of documents that already satisfy validation, while allowing existing invalid documents to be updated without necessarily satisfying the new rule.
  • validationAction: "error" rejects writes that fail validation.
  • validationAction: "warn" permits the write but logs a warning.

Exact behavior and available validator features can vary with server version, so verify against the MongoDB version in use before deploying a validator.

Add validation to an existing collection

db.runCommand({
  collMod: "products",
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["sku", "name"],
      properties: {
        sku: { bsonType: "string" },
        name: { bsonType: "string", minLength: 1 }
      }
    }
  },
  validationLevel: "strict",
  validationAction: "error"
});

Before switching to strict error behavior, inspect existing data and test the validator against representative documents. Validation does not automatically repair historical documents. It also does not replace authorization, business logic, uniqueness constraints, or careful transaction design.

Specifications & Requirements
  • $jsonSchema:A validator format for requiring fields and constraining BSON types and values.
  • validationAction: error:Reject writes that fail validation.
  • validationAction: warn:Allow writes while recording validation warnings.
Note

Roll out strict validation only after auditing existing documents and testing all writers. A validator protects future writes; it does not clean up old data.

Test an insert that violates the required price type
// With the validator above, this should be rejected because price is a string.
db.products.insertOne({
  sku: "KB-900",
  name: "Compact Keyboard",
  price: "4999",
  active: true
});

// Valid shape; use Decimal128 for exact decimal monetary representation.
db.products.insertOne({
  sku: "KB-901",
  name: "Wired Keyboard",
  price: NumberDecimal("1999.00"),
  active: true,
  tags: ["keyboard", "wired"]
});
Key Takeaways
  • Use database validation as a shared safety boundary.
  • Choose BSON types deliberately, especially for dates, identifiers, and monetary values.
  • Audit legacy documents before enabling strict validation.
Knowledge Check

What does a collection validator primarily provide?

Field Types, Missing Fields, Nulls, and Defaults

Be precise about what a field's absence means

MongoDB distinguishes a field that is missing from a field explicitly set to null. These states can carry different meanings.

For example:

  • Missing middleName: the person may not have supplied one.
  • middleName: null: the application explicitly represents the value as unknown or not applicable.
  • middleName: "": the application has stored an empty string, which is a third and often undesirable state.

Queries behave differently depending on the operator. A query { field: null } matches documents where the field is null or missing. Use $exists to test whether the field is present.

// Field is present, including when its value is null
db.users.find({ middleName: { $exists: true } });

// Field is missing
db.users.find({ middleName: { $exists: false } });

// Explicitly null, excluding missing fields
db.users.find({
  middleName: { $type: "null" }
});

Defaults are generally an application concern

MongoDB does not automatically apply arbitrary schema defaults merely because a field is described in JSON Schema. Set defaults in application logic, update pipelines, or a deliberate database workflow. If several services write to the same collection, centralize or standardize the defaulting policy.

Dates and identifiers

Use BSON Date values for timestamps, not inconsistent date strings. Store timestamps in UTC and convert to user-local time at the presentation boundary. Use ObjectId or a consistent chosen identifier type; avoid mixing strings and ObjectIds in the same reference field.

Arrays and scalar values

A field can contain arrays, but querying an array field has semantics that differ from querying a scalar. Schema validation can prevent accidental type drift, such as one writer storing tags as a string while another stores it as an array.

Renaming and removing fields

For a rename, use a staged migration rather than abruptly switching every reader and writer. For removal, first stop writing the field, then backfill or verify, then remove reader support when safe. This avoids breaking rolling deployments.

Specifications & Requirements
  • Missing:The document has no field with that name.
  • Null:The field exists and its value is BSON null.
  • $unset:Removes a field from a document.
Note

Choose a consistent meaning for missing, null, and empty values. Query behavior, uniqueness rules, validation, and application defaults can all be affected by this choice.

Find missing fields separately from explicit nulls
// Matches null OR missing
db.users.find({ middleName: null });

// Matches only documents where middleName exists
db.users.find({ middleName: { $exists: true } });

// Matches explicit BSON null
db.users.find({ middleName: { $type: "null" } });

// Remove a field entirely
db.users.updateOne(
  { _id: userId },
  { $unset: { middleName: "" } }
);
Key Takeaways
  • Do not treat missing, null, and empty string as interchangeable.
  • Store dates and references with consistent BSON types.
  • Define default values and schema migrations consistently across all writers.
Knowledge Check

Which query matches documents where `middleName` is explicitly BSON null but not missing?