Filter concepts by levelShowing all levels.

MongoDB · Section 7

Embedding vs. Referencing

Level
advanced
Read
30 min
Concepts
5

Builds on section 6's embed-or-reference decision with the cardinality strategies for one-to-one, one-to-many, many-to-many, and hierarchical relationships; introduces the six named schema design patterns as a set; and frames denormalization and schema design itself as deliberate, access-pattern-driven choices rather than relational anti-patterns.

MongoDB overview

What is true here

  1. Embed and reference each have three signals; weigh them per relationship, not per document.
  2. Cardinality narrows the modeling strategy, but boundedness — not the cardinality label — decides embed vs. reference.
  3. A referenced one-to-many relationship stores the id on the "many" side, to keep every document's size independent of relationship count.
  4. Six named patterns — subset, bucket, extended reference, computed, attribute, outlier — each solve one specific, measured symptom.
  5. MongoDB schema design is justified by how cheaply it answers real access patterns, not by relational normal-form rules.

What you will be able to do

  • Apply the embed/reference signal checklists to a relationship that does not obviously fall to one side
  • Pick the right modeling strategy for a given cardinality, and place a one-to-many reference on the correct side
  • Recognize which of the six named patterns fits a given schema symptom
  • Justify a deliberate denormalization choice, including its sync plan
  • Explain why a MongoDB schema is not judged by relational normalization rules

Embed, reference, and cardinality

The signal checklists restated, then the modeling strategy for each relationship shape.

Embed-when and reference-when signals

standardintermediate

Three signals point to embedding: read together, bounded, naturally owned by the parent. Three signals point to referencing: grows independently, shared across many parents, has its own lifecycle. Most relationships show a clear majority one way.

Think of it as

This is the same decision as the earlier embed-or-reference question, restated as two checklists instead of a single question — useful when a relationship does not obviously fall to one side. Count how many signals point each way rather than looking for one deciding factor.

text
// Embed signals: read-together + bounded + parent-owned
// Reference signals: independent-growth + shared + separate lifecycle

What we're doing: Apply both checklists to two different relationships from the same order document to show they can land on opposite sides.

signals-in-both-directions.txttext
// Order line items: read together, bounded (an order has a handful of items), owned only by this order
{ _id: 1, items: [ { sku: "X1", qty: 2 }, { sku: "X2", qty: 1 } ] }  // embed

// Customer record: shared across every order they've placed, has its own lifecycle
{ _id: 1, customerId: 987, items: [ ... ] }  // reference
{ _id: 987, name: "...", email: "..." }
2
All three embed signals point the same way here — this is the easy case.
6
Referencing wins because the customer record is shared across many orders and updated on its own schedule, independent of any one order.

Why this works: The same document can hold both an embedded and a referenced relationship, because the two checklists are evaluated per relationship, not once for the whole document.

Applying the checklist to the whole document instead of per relationship

Wrong

text
// "This is an orders collection, so everything about an order should be embedded" — including the customer record

Better

text
// Evaluate each relationship inside the document separately: line items embed, customer reference

What you see: A document ends up either over-embedding (duplicating a widely-shared entity) or over-referencing (an extra query for data that was always read together).

Why: A single collection commonly holds several distinct relationships, and the embed/reference signals can point different ways for each one even within the same parent document.

Remember: Embed: read together, bounded, parent-owned. Reference: independent growth, shared, own lifecycle. Weigh signals per relationship, not per document.

See also: embed or reference decision · relationship cardinality strategies

Modeling strategies by relationship cardinality

coreintermediate

One-to-one usually embeds. One-to-many splits by growth: embed a bounded "few," reference an unbounded "many." Many-to-many always references, with an array of ids on one or both sides. Hierarchical data uses parent/child references or a materialized path.

Think of it as

Cardinality alone does not decide the schema — it narrows the options, and the embed/reference signals decide the rest. "One-to-many" is really two different problems depending on whether "many" means "a handful" or "unbounded": the first embeds fine, the second needs referencing regardless of how naturally related the data feels.

text
// One-to-many, referenced: put the "one" side's id on the "many" side's documents, not an array on the "one" side

What we're doing: Show why storing the parent id on the child, not an id array on the parent, is the referenced one-to-many default.

reference-direction.txttext
// Awkward: an ever-growing array of order ids on the customer document
{ _id: 1, orderIds: [101, 102, 103, /* ...thousands more over the account's life */] }

// Standard: the reference lives on the "many" side instead
{ _id: 101, customerId: 1 }   // order
{ _id: 102, customerId: 1 }   // order
db.orders.find({ customerId: 1 })  // finds all of a customer's orders via an index, no array growth on the customer
2
Putting the id array on the "one" side recreates the same unbounded-array problem referencing was meant to avoid.
6
Putting the reference on the "many" side and querying by it avoids growing any document, and an index on customerId makes the lookup fast.

Why this works: A one-to-many relationship has two documents that could hold the reference; putting it on the "many" side keeps every document's size independent of how many related records exist, which is the whole point of choosing to reference in the first place.

Modeling many-to-many with a growing array on both sides instead of choosing the more selective side

Wrong

text
// Every student document holds every course id, and every course document holds every student id
{ _id: 1, courseIds: [/* all courses ever taken */] }
{ _id: 101, studentIds: [/* every student ever enrolled */] }

Better

text
// Store the id array on the side with fewer, more stable items — students' courses per term, not a course's entire enrollment history

What you see: One side of the many-to-many relationship (typically the more "popular" entity, like a popular course) grows an unbounded array while the other stays small.

Why: Many-to-many referencing still needs a bounded-array check on each side independently — the relationship type does not exempt it from the same bounded-vs-unbounded reasoning that applies to any embedded or referenced array.

Cardinality narrows the choice, it does not decide it

Bounded cardinality

one-to-one

embed

one-to-few

embed, bounded array

Unbounded / shared cardinality

one-to-many

reference, id on the child

many-to-many

reference, id array

hierarchical

parent ref or materialized path

  • Bounded cardinality
    • one-to-one — embed
    • one-to-few — embed, bounded array
  • Unbounded / shared cardinality
    • one-to-many — reference, id on the child
    • many-to-many — reference, id array
    • hierarchical — parent ref or materialized path

Cardinality → default strategy

Cardinality → default strategy
CardinalityDefault strategyExample
One-to-oneembeda user and their single address
One-to-fewembed (bounded array)a product and its size variants
One-to-many (unbounded)reference — parent id on the childa customer and their orders
Many-to-manyreference — array of ids on one/both sidesstudents and the courses they take
Hierarchical / treeparent reference or materialized pathcategory tree, comment threads

Together

text
// One-to-few, embedded:
{ _id: 1, sizes: ["S", "M", "L"] }

// One-to-many, referenced (parent id lives on the child, the "many" side):
{ _id: 987, customerId: 1 }   // an order

// Many-to-many, referenced via an id array:
{ _id: 1, courseIds: [101, 102] }   // a student

Remember: One-to-one/few: embed. One-to-many (unbounded): reference, id on the child. Many-to-many: reference, id array on the more selective side. Trees: parent ref or materialized path.

See also: embed and reference signals · schema design patterns overview · bounded vs unbounded arrays

Advertisement

Patterns and the performance mindset

The six named patterns as a set, deliberate denormalization, and why the whole decision is a performance question.

The named schema design patterns, as a set

standardintermediate

Six recurring solutions to common schema problems, each with a name: subset (trim a large document), bucket (group time-series data), extended reference (copy a few fields to avoid a $lookup), computed (precompute instead of recalculate), attribute (make similar fields queryable together), outlier (handle the rare huge document separately).

Think of it as

Each pattern is a named answer to a recurring "my document/query has this specific problem" question — the same way "singleton" or "observer" name recurring solutions in object-oriented design. Recognizing the problem shape is what lets you reach for the pattern instead of re-deriving a fix from scratch.

text
// Each pattern targets one specific symptom — see section 8 for a worked example of each.

What we're doing: Match three different symptoms to the pattern that addresses each, showing the patterns are chosen by symptom, not by habit.

symptom-to-pattern.txttext
// Symptom: a product document embeds 500 reviews, most reads only need the newest 10
// -> subset pattern: embed the 10 most recent, reference the rest

// Symptom: every order-list page runs a $lookup just to show the customer's name
// -> extended reference pattern: copy { name } onto the order at write time

// Symptom: a dashboard recalculates a running total from thousands of documents on every read
// -> computed pattern: store the running total, update it incrementally on write
2
A large embedded array with a clear "usually only need the recent slice" access pattern is the subset pattern's signature symptom.
5
Extended reference trades a small amount of duplicated, rarely-changing data for eliminating a join on every read of a hot path.
8
Computed trades write-time work (updating the total) for read-time work (recalculating it) — worth it exactly when reads vastly outnumber writes.

Why this works: Naming the symptom first, then reaching for the matching pattern, keeps schema design grounded in an actual measured problem rather than applying a pattern because it sounds sophisticated.

Applying a pattern pre-emptively, before a symptom actually shows up

Wrong

text
// Adding a computed running-total field to every collection "in case it's ever needed", updated on every write

Better

text
// Add the computed field once a real read path is measured recalculating that value repeatedly and expensively

What you see: Every write path now maintains derived fields that no query actually reads yet, adding write cost and drift risk for no measured benefit.

Why: Every one of these patterns trades one cost for another (write complexity for read speed, storage for join avoidance) — applying the trade before the cost it solves is real just pays the cost with nothing to show for it.

Remember: Six named patterns, each solving one specific symptom: subset (trim), bucket (group time-series), extended reference (avoid $lookup), computed (precompute), attribute (sparse fields), outlier (rare huge document). Apply on a measured symptom, not pre-emptively.

See also: relationship cardinality strategies · the six named patterns

Denormalization as an intentional choice, not a shortcut

standardintermediate

Duplicating a field (like copying a customer name onto every order) is normally an anti-pattern in relational design. In MongoDB, doing it on purpose — extended reference, computed values — is a documented trade of some duplication and write cost for far fewer joins and faster reads.

Think of it as

Relational normalization optimizes for "update one place, trust it everywhere" — the database enforces that with foreign keys and joins. MongoDB denormalization optimizes for "read one document, get everything the read needs" — and accepts that the duplicated copy can drift unless something (an update pattern, or accepting eventual staleness) keeps it in sync.

text
// Denormalize a specific field for a specific measured read cost — not "duplicate everything, everywhere"

What we're doing: Show a deliberate denormalization decision, including the explicit plan for keeping the copy in sync.

deliberate-denormalization.txttext
// Normalized (relational instinct): order only stores a reference
{ _id: 101, customerId: 1 }
// -> every order-list page needs a $lookup or a second query to show the customer's name

// Denormalized on purpose: a copy of the rarely-changing display name lives on the order
{ _id: 101, customerId: 1, customerName: "Joel M" }
// -> the order-list read needs no join; a rename updates existing orders only if that's a real requirement
2
The normalized version is correct by relational standards but costs a join on the single most common read (the order list).
6
The denormalized copy is a name, chosen because names change rarely — the decision explicitly accepts that a rename will not retroactively update past orders unless a migration does it.

Why this works: The word "denormalization" sounds like settling for something worse, but here it names a specific, bounded trade — a rarely-changing field, copied to remove a join from a hot read path, with an explicit answer for what happens if the source value changes.

Denormalizing a frequently-changing field without a sync plan

Wrong

text
// Copying a "current balance" or "follower count" onto every document that references the account, with no update path when it changes

Better

text
// Denormalize fields that change rarely (a name), or accept and design for staleness explicitly for fields that change often

What you see: A frequently-changing denormalized value drifts out of sync across many documents, and nothing in the design says which copy (if any) is authoritative.

Why: Denormalization is only "intentional" if the volatility of the duplicated field and the plan for keeping it current were both actually considered — copying a fast-changing value without a sync plan just recreates the classic anti-pattern relational normalization exists to prevent.

Remember: Denormalize a specific, usually slow-changing field to remove a join from a measured hot read path — always with an explicit answer for how the copy stays (or is allowed to go) stale.

See also: schema design patterns overview · embed or reference decision

Schema design as a performance decision

standardintermediate

A relational schema is judged mainly by correctness: does it avoid duplicate/inconsistent data? A MongoDB schema is judged mainly by performance: does it answer the app's real queries in as few reads as possible? Both must be correct; only one is primarily justified by fewer joins.

Think of it as

Normalized relational design treats "no duplicated data" as close to a first principle, with performance as a secondary tuning step (indexes, query optimization) layered on afterward. MongoDB schema design treats the application's access patterns as the first input, and correctness is maintained by design (validation, atomic updates) rather than by a single unique-source-of-truth structure.

text
// The question is not "is this schema normalized" — it is "does this schema answer our real queries cheaply"

What we're doing: Show the same two entities modeled two different, both-valid ways because the dominant access pattern differs.

same-entities-different-schema.txttext
// App A: mostly reads a blog post with its comments together, comments rarely queried alone
{ _id: 1, title: "...", comments: [ { user: "...", text: "..." } ] }  // embed

// App B: comments are heavily queried on their own (a "recent comments across the site" feed)
{ _id: 501, postId: 1, user: "...", text: "..." }  // reference, its own collection, indexed by postId
2
Embedding is the right call when the dominant read is "show a post with its comments" — one document, one read.
5
The same entities reference instead when a different access pattern (querying comments independently of any one post) dominates — embedding would make that pattern expensive or impossible to index well.

Why this works: Neither schema is "more correct" in the relational sense — both are valid document models of the same two entities. The deciding factor is which access pattern the application actually needs to serve cheaply, which is a performance question, not a normalization question.

Judging a MongoDB schema by relational normalization rules instead of its actual access patterns

Wrong

text
// "This schema duplicates data, so it must be wrong" — applied without checking what queries the app actually runs

Better

text
// Ask what the schema needs to answer, and how cheaply, before judging whether a given duplication is a problem

What you see: A schema gets "fixed" toward relational normalization (removing embedded duplication, adding more references) and the app's formerly single-document reads start needing several queries or $lookups.

Why: Relational normalization rules solve a problem (update anomalies from redundant data) that a schema justified by access patterns and controlled duplication has already accounted for on its own terms — applying the relational rule anyway optimizes for a goal this schema was never trying to hit.

Remember: A MongoDB schema is judged by whether it answers the app's real access patterns cheaply — not by relational normal-form rules. The same entities validly model differently depending on which queries matter most.

See also: denormalization is intentional · workload driven design

Advertisement