MongoDB Mastery
MongoDB Mastery lessons / Module 12: Schema Design

Embedding vs referencing

Level 2: Intermediate25 min

Design for how the application reads and writes. Learn the rules of thumb for embedding versus referencing based on relationship cardinality, growth and access patterns.

This lesson is interactive. Run every example against an in-browser MongoDB, see explain() plans drawn as trees, and take the quiz.
Open the interactive lesson
You will learn to
  • Model one-to-one, one-to-few, one-to-many and many-to-many relationships
  • Decide between embedding and referencing
  • Recognize unbounded growth and the 16 MB risk
  • Accept and manage controlled duplication

conceptThe concept

Open the interactive lesson to use this diagram.

The guiding rule: data that is accessed together should be stored together. Start from the queries, not the entities.

RelationshipTypical designExample
One-to-oneEmbedcustomer and its preferences
One-to-few (bounded)Embed an arraycustomer addresses, order line items
One-to-many (hundreds+)Reference from the "many" sideorders.customerId
One-to-squillionsReference from the child, never an array in the parentevents per user
Many-to-manyArray of ids on one or both sides, or a link collectionstudents and courses

Embed when the child is read with the parent, is bounded in number, and is owned by the parent. Reference when the child is large, unbounded, shared by many parents, updated independently, or queried on its own.

whyWhy it matters

Schema is the biggest lever on performance. A good schema makes the common query a single indexed read; a bad one needs joins, multiple round trips, or ever-growing documents that degrade every write. Fixing a schema later means a data migration on a live system.

Use caseReal-world scenario

The shop embeds line items in orders and copies name, category and price from the product at purchase time. That copy is intentional: the order must show what the customer paid even if the product price changes tomorrow. Reviews live in their own collection because a product can have thousands.

Try itExamples

Run and edit these in the interactive lesson.

Embedded: one read for the order page
db.orders.findOne({ orderNo: "ORD-100123" }, { _id: 0, orderNo: 1, status: 1, items: 1, shipping: 1, payment: 1 })
Referenced: reviews by product
db.reviews.countDocuments({ productId: "P010" })
db.reviews.find({ productId: "P010" }, { _id: 0, rating: 1, title: 1, helpfulVotes: 1 }).sort({ helpfulVotes: -1 }).limit(3)
Why the copy of price matters
const line = db.orders.findOne({ "items.productId": "P003" }, { items: { $elemMatch: { productId: "P003" } } }).items[0]
const product = db.products.findOne({ _id: "P003" }, { price: 1 })
print("paid at purchase:", line.price, " current catalog price:", product.price)
Measure document sizes
db.orders.aggregate([
  { $project: { size: { $bsonSize: "$$ROOT" }, lines: { $size: "$items" } } },
  { $group: { _id: null, avgBytes: { $avg: "$size" }, maxBytes: { $max: "$size" }, maxLines: { $max: "$lines" } } }
])

internalsUnder the hood

Bigger documents mean fewer documents per page in the WiredTiger cache, so if most reads only need a few fields of a large document, you waste cache and IO. Every update rewrites the whole document in the storage engine. These are the physical reasons behind "bounded embedding" and the subset pattern.

Avoid / preferDo this, not that

Avoid
{ _id: 66, name: "Asha", orders: [ /* every order she ever placed */ ] }
Prefer
// customers: { _id: 66, name: "Asha" }
// orders:    { customerId: 66, createdAt: ..., items: [...] }
db.orders.createIndex({ customerId: 1, createdAt: -1 })

Orders grow without bound and are queried on their own. A separate collection with an index on customerId scales; an embedded array eventually hits 16 MB.

Avoid
// addresses: { customerId: 66, city: "Pune", zip: "411001" }
db.customers.aggregate([{ $lookup: { from: "addresses", ... } }])
Prefer
db.customers.insertOne({ _id: 66, name: "Asha",
  address: { city: "Pune", zip: "411001" } })

A one-to-one child that is always read with its parent belongs inside it. A separate collection only adds a join.

PitfallsCommon mistakes

  • Copying a relational schema one table per collection and joining everything with $lookup.
  • Embedding unbounded children (all orders inside the customer).
  • Refusing any duplication. Duplicating fields that rarely change (names, prices at a point in time) is normal; just decide how each copy is updated.
  • Designing without listing the top queries and their frequency first.

LimitsWhen not to use it

Embedding is wrong when the child is shared by many parents and changes often, when the child list is unbounded, or when children are queried on their own at a high rate. Referencing is wrong when the data is always read together and small. Decide from the access patterns, not from entity diagrams.

SecuritySecurity notes

Embedding mixes sensitivity levels. If a customer document embeds payment tokens or identity documents, every query that returns the customer risks returning them. Keep sensitive data in a separate collection with tighter roles, or encrypt the fields client-side.

ChallengeTry it yourself

To decide whether embedding line items is safe, compute the average and maximum number of items per order.

Show one solution
One solution
db.orders.aggregate([
  { $group: { _id: null, avgItems: { $avg: { $size: "$items" } }, maxItems: { $max: { $size: "$items" } } } },
  { $project: { _id: 0, avgItems: { $round: ["$avgItems", 2] }, maxItems: 1 } }
])

InterviewQuestions you should be able to answer

How do you decide between embedding and referencing?

Ask: Is the child read together with the parent? Is the number of children bounded and small? Is the child owned by one parent? Is it updated independently or at high frequency? Embed if the answers favour locality and boundedness; reference if the child is unbounded, shared, large, or accessed alone. Then validate with the top queries, their frequency, and the write patterns.

How do you model many-to-many?

Usually an array of ids on the side that is read most (for example course.studentIds if bounded, or student.courseIds). If both sides are large or the relation has attributes (enrollment date, grade), use a link collection enrollments { studentId, courseId, ... } indexed on both fields.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.