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.

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
_idfield (like a foreign key)
Neither is universally better. The decision depends on three questions:
- How is the data accessed? If related data is always read together, embed it. If it is queried independently, reference it.
- How often does the related data change? Frequently updated embedded data requires updating every parent document that contains it.
- 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:
// 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:
// 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:
// 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:
// 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)
// 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)
// 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:
// 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:
// 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 queriesThe Polymorphic Pattern
When documents in a collection have different shapes but share some common fields, use a type discriminator:
// 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:
// 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-pattern | Problem | Fix |
|---|---|---|
| Unbounded array growth | Document hits 16MB limit; write performance degrades | Use a separate collection or bucket pattern |
| Massive number of collections | Memory overhead; harder to query across | Use a type field instead of one collection per type |
Using _id as a foreign key from another system | Collision risk; no ObjectId benefits | Use a separate indexed field like externalId |
| Normalising everything like a relational schema | Requires expensive $lookup on every read | Embed data that is always read together |
| Embedding everything | Large documents; slow partial updates; replication lag | Reference data that changes frequently or is queried independently |
Validation with JSON Schema
MongoDB supports schema validation rules per collection:
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"
})