MongoDBDatabases

MongoDB Schema Design & Data Modelling: Embedding vs Referencing

Learn MongoDB schema design patterns: when to embed vs reference, one-to-many and many-to-many relationships, polymorphic schemas, and versioning. Lesson 4 of MongoDB & NoSQL Mastery.

TT
Sarah Mitchell
6 min read
MongoDB Schema Design & Data Modelling: Embedding vs Referencing

Schema design is the highest-leverage skill in MongoDB. A well-designed schema makes queries fast, updates simple, and the application code clean. A poorly designed schema forces expensive $lookup aggregations, unbounded array growth, and document sizes that exceed MongoDB's 16MB limit. Unlike relational databases, there is no single correct schema — the right design depends entirely on your access patterns.

Previous: Lesson 3 — MongoDB CRUD Operations


The Central Question: Embed or Reference?

Every relationship in MongoDB comes down to a choice:

  • Embed: store related data as a nested sub-document or array inside the parent document
  • Reference: store the related document in a separate collection and link via an _id field (like a foreign key)

Neither is universally better. The decision depends on three questions:

  1. How is the data accessed? If related data is always read together, embed it. If it is queried independently, reference it.
  2. How often does the related data change? Frequently updated embedded data requires updating every parent document that contains it.
  3. How large can the embedded data grow? MongoDB documents have a 16MB limit. Arrays that grow without bound must be referenced.

Embedding: Denormalised Documents

One-to-One: Always Embed

When two entities have a strict one-to-one relationship and are always accessed together, embed unconditionally:

javascript
// User with embedded address — always read together, no shared access
{
  _id: ObjectId("..."),
  name: "Alice Nguyen",
  email: "alice@example.com",
  address: {
    street: "42 Elm Street",
    city: "Austin",
    state: "TX",
    postcode: "78701"
  }
}

One-to-Few: Embed the Array

When a parent has a small, bounded number of related items that are always read with the parent, embed as an array:

javascript
// Blog post with embedded comments — read together, bounded count
{
  _id: ObjectId("..."),
  title: "Getting Started with MongoDB",
  body: "...",
  tags: ["mongodb", "nosql", "tutorial"],
  comments: [
    { author: "Bob", body: "Great article!", createdAt: ISODate("2026-01-15") },
    { author: "Carol", body: "Very helpful.", createdAt: ISODate("2026-01-16") }
  ]
}

When to stop embedding comments: when the number of comments per post is unbounded (high-traffic site), switch to a separate comments collection referenced by postId.

One-to-Many: Reference or Bucket

For true one-to-many with unbounded growth, reference:

javascript
// Order references line items in a separate collection
// orders collection
{
  _id: ObjectId("order_001"),
  customerId: ObjectId("user_123"),
  status: "shipped",
  total: 259.97,
  createdAt: ISODate("2026-05-01")
}

// order_items collection
{ _id: ObjectId("..."), orderId: ObjectId("order_001"), productId: ObjectId("..."), qty: 2, price: 79.99 }
{ _id: ObjectId("..."), orderId: ObjectId("order_001"), productId: ObjectId("..."), qty: 1, price: 99.99 }

For time-series or event data, consider the bucket pattern — group related events into a single document by time window:

javascript
// IoT sensor readings — one document per sensor per hour
{
  sensorId: "temp-sensor-01",
  date: ISODate("2026-05-20T14:00:00Z"),
  readings: [
    { ts: ISODate("2026-05-20T14:00:00Z"), value: 21.4 },
    { ts: ISODate("2026-05-20T14:01:00Z"), value: 21.6 },
    // ... up to 60 readings per document
  ],
  count: 60,
  min: 21.1,
  max: 22.3,
  avg: 21.7
}

Many-to-Many Relationships

Embed IDs in Both Directions (Small Sets)

javascript
// Product with embedded category IDs
{
  _id: ObjectId("prod_001"),
  name: "Mechanical Keyboard",
  categoryIds: [ObjectId("cat_electronics"), ObjectId("cat_peripherals")]
}

// Category with embedded product IDs (only viable if count is small)
{
  _id: ObjectId("cat_electronics"),
  name: "Electronics",
  productIds: [ObjectId("prod_001"), ObjectId("prod_002"), ...]
}

Reference via a Join Collection (Large Sets)

javascript
// products collection — no embedded relationship
{ _id: ObjectId("prod_001"), name: "Mechanical Keyboard" }

// categories collection
{ _id: ObjectId("cat_electronics"), name: "Electronics" }

// product_categories join collection
{ productId: ObjectId("prod_001"), categoryId: ObjectId("cat_electronics") }
{ productId: ObjectId("prod_001"), categoryId: ObjectId("cat_peripherals") }

Common Schema Patterns

The Computed Pattern

Pre-compute and store derived values to avoid expensive aggregations on every read:

javascript
// Order document with pre-computed total
{
  _id: ObjectId("..."),
  items: [
    { name: "Keyboard", qty: 1, price: 129.99 },
    { name: "Mouse", qty: 2, price: 49.99 }
  ],
  subtotal: 229.97,    // pre-computed — updated on every item change
  tax: 18.40,
  total: 248.37
}

The Attribute Pattern

When a document has many fields that are sparse (present on some documents but not others), store them as a key-value array to enable consistent indexing:

javascript
// Instead of: { color: "red", size: "M", material: "cotton", ... }
// Use:
{
  _id: ObjectId("..."),
  name: "T-Shirt",
  attributes: [
    { key: "color",    value: "red" },
    { key: "size",     value: "M" },
    { key: "material", value: "cotton" }
  ]
}
// Index on attributes.key and attributes.value covers all attribute queries

The Polymorphic Pattern

When documents in a collection have different shapes but share some common fields, use a type discriminator:

javascript
// A single "content" collection for different content types
{ _id: ObjectId("..."), type: "article", title: "...", body: "...", author: "Alice" }
{ _id: ObjectId("..."), type: "video",   title: "...", url: "...",  duration: 420 }
{ _id: ObjectId("..."), type: "podcast", title: "...", url: "...",  episodes: 12 }

The Outlier Pattern

When most documents are small but a few grow large (viral posts with thousands of comments), add an overflow flag:

javascript
// Standard post document
{ _id: ObjectId("..."), title: "...", comments: [...], hasOverflow: false }

// High-traffic post — overflow stored separately
{ _id: ObjectId("..."), title: "...", comments: [...first 1000...], hasOverflow: true }

// Overflow documents
{ _id: ObjectId("..."), postId: ObjectId("..."), comments: [...next 1000...] }

What Not to Do: Anti-Patterns

Anti-patternProblemFix
Unbounded array growthDocument hits 16MB limit; write performance degradesUse a separate collection or bucket pattern
Massive number of collectionsMemory overhead; harder to query acrossUse a type field instead of one collection per type
Using _id as a foreign key from another systemCollision risk; no ObjectId benefitsUse a separate indexed field like externalId
Normalising everything like a relational schemaRequires expensive $lookup on every readEmbed data that is always read together
Embedding everythingLarge documents; slow partial updates; replication lagReference data that changes frequently or is queried independently

Validation with JSON Schema

MongoDB supports schema validation rules per collection:

javascript
db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["name", "email", "createdAt"],
      properties: {
        name:      { bsonType: "string", minLength: 1 },
        email:     { bsonType: "string", pattern: "^.+@.+\\..+$" },
        age:       { bsonType: "int",    minimum: 0, maximum: 150 },
        createdAt: { bsonType: "date" }
      }
    }
  },
  validationLevel: "strict",
  validationAction: "error"
})