The biggest leap from SQL to MongoDB is the way you model data. There's no "right" normalisation — schema is chosen per access pattern. Get it right and a single document satisfies the hot read with one disk seek. Get it wrong and you're stitching together with $lookup on every page load. This post covers the canonical design patterns: embed vs reference, 1:1 / 1:few / 1:many / many:many, plus the named patterns the MongoDB community has codified — Polymorphic, Bucket, Computed, Extended Reference, Attribute, Tree (4 variants), Outlier, Schema Versioning.

Series map: 00 Foundations → 01 CRUD → 02 Query Operators → 03 Indexes → 04 Query Optimization → 05 Aggregation Basics → 06 Aggregation Advanced → 07 Schema Design (this post) → 08 Connection Pool & Concurrency → … → 17 Drivers & Case Studies.


Learning Outcome

  • Decide between embedding and referencing for each relationship.
  • Recognise the canonical access patterns for 1:1, 1:few, 1:many, many:many.
  • Apply the named patterns: Polymorphic, Bucket, Computed, Extended Reference, Attribute, Tree, Outlier, Schema Versioning.
  • Store hierarchies four ways (parent ref / child ref / array of ancestors / materialised path).
  • Plan schema evolution without downtime.

Chapter 1 — The mental shift

SQL: design schema first, query later.
MongoDB: start from the queries you want to run, design schema to make them efficient.

Two principles:

  1. Data that's accessed together should live together. Embed it.
  2. Data that grows unboundedly or is shared across many parents should be separated. Reference it.

That's it. The named patterns below are corollaries.


Chapter 2 — Embed vs reference

Embed (sub-document or array)

1
2
3
4
5
6
7
8
9
10
{
_id: 1,
name: "Ada",
address: { // embedded sub-document
city: "London",
country: "UK"
},
emails: ["ada@x", "ada@y"] // embedded array
}

Pros:

  • One document fetch returns the whole thing.
  • Atomic writes across the whole document.
  • No joins.

Cons:

  • Duplication if the same sub-document is shared by many parents.
  • Document size cap: 16 MB.
  • Updating a deeply nested value can be awkward.

Reference (foreign key style)

1
2
3
4
5
6
// users
{ _id: 1, name: "Ada" }

// orders
{ _id: "o1", user_id: 1, items: [...] }

Pros:

  • Each entity is the single source of truth.
  • No size limit (each is its own document).
  • Many parents can reference the same entity.

Cons:

  • Reads need a join ($lookup or two round-trips).
  • No atomic writes across the reference unless using transactions.

Decision matrix

Embed Reference
Always read together ✓
Independent reads dominant ✓
Few items per parent (< ~100) ✓
Many items per parent (> 1000) ✓
Item shared by many parents ✓
Item grows unbounded ✓
Item updated often, on its own ✓
Items rarely change once written ✓

Trick: when undecided, embed first. Schema migration from embedded to referenced is mechanical. The reverse usually requires re-engineering query paths. Embedding is the cheap default.


Chapter 3 — Cardinality patterns

1:1 — always embed

1
2
{ _id: 1, name: "Ada", profile: { bio: "...", avatar: "..." } }

1:few — embed (bounded array)

A user's emails (≤ ~10), a product's color variants (≤ ~20).

1
2
{ _id: 1, name: "Ada", emails: ["ada@x", "ada@y", "ada@z"] }

1:many — embed if bounded, reference if not

A blog post's comments (could be 1 or 10,000) → reference.
A user's purchase history (could be 1 or 10,000) → reference (separate orders collection).
A book's chapters (10-50 typically) → embed.

1
2
3
// reference: each comment its own doc
{ _id: "c1", post_id: "p1", author: "Bob", text: "..." }

Many:many — usually reference both ways

Users and tags: tags as separate documents; user has tag_ids: [...]; tag has user_count: N (denormalised count).

For small membership lists (a user's roles), embed the IDs in the user doc.

"1 to squillions" — reference parent on the child

A device with millions of telemetry events.

1
2
3
4
5
6
// device
{ _id: "d1", name: "sensor-foo" }

// readings — bucket pattern (Chapter 6) often beats raw 1-per-doc
{ device_id: "d1", at: ISODate(...), temp: 23.4 }

Never try to embed millions of children into the parent — the 16 MB cap kicks in.


Chapter 4 — Polymorphic pattern

When documents in the same collection have different shapes (different fields per type):

1
2
3
4
5
// products collection
{ _id: "p1", type: "book", title: "...", isbn: "...", pages: 320 }
{ _id: "p2", type: "shirt", title: "...", size: "M", colour: "red" }
{ _id: "p3", type: "course", title: "...", duration_hours: 12, instructor: "..." }

Common base fields (title, price, category) + a discriminator (type) + type-specific fields. Index type for filtering.

When to use: catalogue with many product kinds; events with type-specific payloads; logs with varied formats.

In code, a switch on type decides how to render / process. Trade richer schema for fewer collections + simpler queries.


Chapter 5 — Bucket pattern (pre-time-series collections, still useful)

Instead of one document per event, group events into "buckets" of N (by time or count):

1
2
3
4
5
6
7
8
9
10
11
12
13
{
_id: ObjectId(...),
device_id: "d1",
bucket_start: ISODate("2026-05-24T12:00:00Z"),
bucket_end: ISODate("2026-05-24T13:00:00Z"),
count: 60,
measurements: [
{ at: ISODate("2026-05-24T12:00:00Z"), temp: 23.1 },
{ at: ISODate("2026-05-24T12:01:00Z"), temp: 23.2 },
...
]
}

One bucket per hour per device → 24 docs/day/device instead of 86,400.

MongoDB 5.0+ ships native time-series collections that automate the bucket pattern + add columnar compression — Post 17 walks through them. Hand-rolled buckets are still useful when you need custom grouping (e.g., per session) or pre-5.0 clusters.


Chapter 6 — Computed pattern

Pre-compute aggregates and store them on the parent document — avoid repeating expensive $group aggregations on every read.

1
2
3
4
5
6
7
8
{
_id: 1,
name: "Ada",
order_count: 42, // computed
total_spent: 1234.50, // computed
last_order_at: ISODate(...) // computed
}

Update via the same write that creates the related event (transaction or bulkWrite) or via change-stream-driven background updates.

Trade-off: write amplification for read speed. Worth it when the read is hot and the computation expensive.


Chapter 7 — Extended Reference pattern

Reference an entity by ID plus duplicate a few stable fields on the referencing side — saves the join for the most common read.

1
2
3
4
5
6
7
8
9
// orders
{
_id: "o1",
user_id: 1,
user_name: "Ada", // duplicated from users
user_email: "ada@x", // duplicated
items: [...]
}

Reads listing orders don't need to $lookup users for name + email. Drawback: if the user changes their name, orders show stale.

Use when:

  • The denormalised fields rarely change.
  • Read frequency >> write frequency.
  • Stale data for hours/days is acceptable until a background job catches up.

Chapter 8 — Attribute pattern

When documents have many similar fields you need to query over arbitrary subsets:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// hard to query: { weight_kg: 1.2, height_cm: 30, voltage_v: 5 }
// easy to query: array of typed attributes
{
_id: "p1",
attributes: [
{ k: "weight", v: 1.2, unit: "kg" },
{ k: "height", v: 30, unit: "cm" },
{ k: "voltage", v: 5, unit: "v" }
]
}

db.products.createIndex({ "attributes.k": 1, "attributes.v": 1 })
db.products.find({ attributes: { $elemMatch: { k: "weight", v: { $gte: 1 } } } })

A single multikey compound index serves all attribute-based queries.

Trade-offs: more rigid query syntax ($elemMatch), unfamiliar to SQL background. Choose when products / items have hundreds of possible attributes only some of which apply per item.


Chapter 9 — Tree patterns (4 ways to store hierarchies)

Hierarchies (org charts, comment threads, categories) have four canonical storage shapes. Pick by access pattern.

1. Parent reference

1
2
3
4
{ _id: "c1", name: "Electronics" }
{ _id: "c2", name: "Laptops", parent: "c1" }
{ _id: "c3", name: "Gaming", parent: "c2" }
  • Write: simple.
  • Find children: find({ parent: "c1" }) — easy.
  • Find ancestors / whole subtree: $graphLookup or recursion.

Default for hierarchies you don't traverse deeply.

2. Child references

1
2
{ _id: "c1", name: "Electronics", children: ["c2", "c5"] }
  • Find children: read the array.
  • Find parent: find({ children: "c2" }).
  • Move a node: update old + new parent.

Useful when children are bounded + you list them often.

3. Array of ancestors

1
2
{ _id: "c3", name: "Gaming", ancestors: ["c1", "c2"], parent: "c2" }
  • Find ancestors: read the array (no join).
  • Find subtree: find({ ancestors: "c1" }) — instant.
  • Move a node: update ancestors of node + all descendants.

Best when you query "all descendants" frequently.

4. Materialised path

1
2
{ _id: "c3", name: "Gaming", path: ",c1,c2," }
  • Find subtree: find({ path: /^,c1,/ }).
  • Find ancestors: split the string.
  • Move: rewrite paths for node + descendants.

Compact; regex-prefix index works (Post 02).

Comparison

Pattern Find direct children Find full subtree Move node Storage
Parent ref easy expensive ($graphLookup) easy tiny
Child ref easy expensive medium medium
Ancestors medium easy medium-hard medium
Path medium easy (regex prefix) medium-hard small

Mix is fine — store parent ref and ancestors for both kinds of query.


Chapter 10 — Outlier pattern

A few documents in a collection are atypically large (a celebrity user with 50k followers; a viral post with 1M comments). Embedding fails the 16 MB limit for them.

Mark them as outliers + store the overflow elsewhere:

1
2
3
4
5
6
7
8
// most users
{ _id: 1, name: "Ada", followers: ["u2", "u3"] } // small array embedded

// celebrity
{ _id: 99, name: "Famous", has_overflow: true }
// followers in a separate collection
{ user_id: 99, followers_batch: [...], batch_index: 0 }

App reads check has_overflow and fetch from the side collection if true.

Avoids pathological queries on average users while still scaling for outliers.


Chapter 11 — Schema versioning pattern

MongoDB schema is flexible — apps must handle multiple versions during migrations. Make the version explicit:

1
2
3
{ _id: 1, _schema_v: 1, name: "Ada", emails: ["ada@x"] }
{ _id: 2, _schema_v: 2, name: "Bob", contact: { emails: ["bob@x"], phone: "..." } }

App-level:

1
2
3
4
5
6
7
function getUserContact(doc) {
switch (doc._schema_v) {
case 1: return { emails: doc.emails };
case 2: return doc.contact;
}
}

Migrate documents lazily (on read or via a background job) — never block the system for a big-bang migration. When all docs are at v2, drop the v1 code path.


Chapter 12 — Schema validation (optional but recommended)

MongoDB doesn't require a schema, but you can enforce one with $jsonSchema:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
db.createCollection("users", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["name", "email"],
properties: {
name: { bsonType: "string" },
email: { bsonType: "string", pattern: "^.+@.+$" },
age: { bsonType: "int", minimum: 0, maximum: 150 }
}
}
},
validationLevel: "moderate", // strict | moderate | off
validationAction: "error" // error | warn
})

moderate validates new docs + updated existing docs but allows old docs through. Good for gradual rollout.

Mongoose (Post 17) adds schema enforcement in the application layer with richer types + defaults + middleware.


Chapter 13 — Avoiding pathological growth

Unbounded arrays are the single biggest schema pitfall:

1
2
3
// danger: comments grow forever, may exceed 16 MB
{ _id: "p1", title: "...", comments: [/* could be 1M elements */] }

Symptoms: slow updates (CoW on the whole doc), index bloat, eventual 16 MB cap.

Fixes in priority order:

  1. Cap the array — $push: { items: { $each: [x], $slice: -100 } } keeps last 100.
  2. Move to a child collection with parent_id.
  3. Use the bucket pattern (Chapter 5) or time-series collection.

Chapter 14 — Worked example — e-commerce

Entity Storage Why
Product (catalogue) own collection shared by many orders, listed independently
Category tree parent ref + ancestors "find all products in subtree" is common
User own collection independent
User address book embedded (bounded ≤ ~5) always read with user
Order own collection unbounded number per user, queried independently
Order items embedded in order always read with order, bounded ≤ ~50
Item product snapshot extended reference catalogue may change after order placed
Cart own collection live, may be huge for window shoppers; or embedded in user if small
Review own collection with product_id many per product, queried by product or user
Audit log own collection, TTL-indexed high volume, time-bounded

This is the kind of map to draw before any project — for each entity, "where does it live, how is it linked".

An e-commerce schema map showing what is embedded and what is referenced


Chapter 15 — Common pitfalls

Pitfall Fix
Embed unbounded array cap with $slice or move to child collection
Reference everything by default embed when always read together
Forget to denormalise stable fields extended reference pattern
Sub-document mutation requires fetching whole doc OK; just plan for it
Cross-collection consistency use transactions (Post 09) or accept eventual
Schema changes break old code schema versioning pattern
All-cases-in-one collection (sparse fields) OK if polymorphic with discriminator
16 MB doc cap hit outlier pattern + side collection

Practice Exercises

Exercise 1 — Embed vs reference quiz

For each, pick embed or reference and justify:

a) Blog post + author name + author bio (rarely changes).
b) Blog post + comments (unbounded, paginated separately).
c) Order + line items (typically 1-20 per order).
d) User + login history (years of logins).

Answers

a) Extended reference — duplicate name + author_id; bio on the author doc (rare reads).
b) Reference (separate comments collection).
c) Embed line items.
d) Reference (logins collection with TTL).

Exercise 2 — Build a category tree

Choose any approach from Chapter 9. Insert a 3-level tree (Electronics → Laptops → Gaming). Query for "all categories under Electronics".

Exercise 3 — Computed pattern

Add order_count + total_spent to each user. Use change streams (Post 12 preview) or a one-time aggregate + $merge to populate.

Exercise 4 — Outlier

In users, give one user a followers array of 100k IDs. Profile insert time + read time. Refactor as outlier pattern.

Exercise 5 — Schema validation

Apply $jsonSchema validation to users: name (string, required), email (matches regex, required), age (int 0-150). Try to insert an invalid doc.

Exercise 6 — Schema versioning

Migrate the users collection: rename email to contact.email. Use _schema_v: 2 to distinguish; write app code that handles both versions.


Cheat-sheet

Pattern When
Embed always read together; small + bounded
Reference unbounded / shared / independent
Polymorphic many types in one collection + discriminator field
Bucket time-series before TSC; high-volume small events
Computed pre-aggregate hot read on parent
Extended Reference duplicate stable fields to skip join
Attribute many sparse fields → array of {k, v, unit}
Tree: parent ref default; cheap writes
Tree: ancestors array subtree queries
Tree: materialised path small + regex-prefix friendly
Outlier a few docs much larger than rest
Schema versioning track _schema_v per doc; migrate lazily
Validation $jsonSchema in createCollection
Default embed first, refactor later if needed
16 MB cap hard limit per document
Unbounded array always cap or move out

What's next

Post 08 covers connection pool, write concern, read concern, read preference, retryability — the driver options and durability settings that decide how many concurrent operations your app sustains and how much data loss it tolerates.


References

Note: this post is in progress. Planned additions: anti-pattern detector script, schema-evolution from production case studies, multi-tenant patterns (shared collection vs collection-per-tenant vs database-per-tenant).