Schema Design: Embed or Reference
In a SQL database there is usually one "correct" normalised design for a set of tables. In MongoDB the right design depends on how your application uses the data: which pages it shows, which queries run thousands of times a minute and which data always changes together. The central question for every relationship is simple: should related data be embedded in one document, or kept in a separate collection and referenced by its _id?
In this lesson you will learn how to answer that question, why the shop data looks the way it does, and the most useful design patterns. Start with freshly loaded data and use shop.
Embedding and referencing
Embedding puts related data inside the parent document, as a sub-document or an array. Referencing stores only the other document's _id, and the application or a $lookup fetches it when needed.
| Embedding | Referencing | |
|---|---|---|
| Reading | One query returns everything | Needs a second query or $lookup |
| Writing | One atomic write updates parent and children together | Changes to related data are separate writes |
| Duplication | Data may be copied into many documents | Each fact is stored once |
| Size | Limited by the 16 MB document limit | No limit on the number of related documents |
| Best for | Data read together, owned by the parent, small and bounded | Data that grows without limit, is shared or is read on its own |
The guiding rule is: data that is accessed together should be stored together, unless it can grow without limit.
Sizing the relationship
Ask "how many?" for each relationship, because the answer usually decides the design:
- One-to-one and one-to-few: embed. A customer has one to three delivery addresses, always shown with the profile.
- One-to-many: reference from the "many" side. A customer has dozens or hundreds of orders, so each order stores
customerId, as in the shop. - One-to-squillions: always reference from the child. A product might collect millions of page-view events; an array of them inside the product would hit 16 MB.
- Many-to-many: store an array of IDs on the side you query from, for example a wish list holding
productIds.
Here is the one-to-few case. Embedded addresses come back with the customer in a single read, and dot notation still queries them:
db.customers.updateOne({ _id: 1 }, { $set: { addresses: [
{ label: 'home', city: 'Jaipur', pincode: '302001' },
{ label: 'office', city: 'Jaipur', pincode: '302017' }
] } })
db.customers.find({ 'addresses.pincode': '302017' }, { name: 1, addresses: 1 })[
{
_id: 1,
name: 'Aarav Sharma',
addresses: [
{ label: 'home', city: 'Jaipur', pincode: '302001' },
{ label: 'office', city: 'Jaipur', pincode: '302017' }
]
}
](The updateOne() result is not shown.) The addresses belong to Aarav alone, there are only a few, and the checkout page needs them with his profile, so embedding is the natural choice.
Why the shop is designed this way
The practice data was designed with these rules:
- Orders reference customers (
customerId). A customer can place hundreds of orders over the years; embedding them all in the customer would make an ever-growing document. - Order items are embedded in orders. An order has a handful of items, they are always shown with the order, and they must never change once the order is placed. Placing an order is one atomic
insertOne(). - Items copy the product name and price. This is the extended reference pattern below, and it also records the price actually paid.
- Products stand alone. Thousands of orders share each product, so products are referenced by
productId, never embedded whole.
Pattern: extended reference
An extended reference stores the ID of a related document plus a copy of the few fields you need most often. The order items hold productId and also name and price, so an order page needs no lookup at all. The trade-off is duplication: if a copied field changes and you need the copies to follow (a product rename, say), you must update them too, for example with updateMany() and arrayFilters. Copy fields that rarely change, or that should not change, such as a price at the time of sale.
Pattern: subset
A product can have thousands of reviews, but the product page shows only the latest three. The subset pattern keeps all reviews in a reviews collection and embeds just the newest few in the product. $push with $each and $slice adds a review and trims the array in one atomic step, and the same update can keep running totals:
db.products.updateOne(
{ _id: 105 },
{
$push: { recentReviews: { $each: [{ by: 'Priya', stars: 4 }], $slice: -3 } },
$inc: { reviewCount: 1, starTotal: 4 }
}
)
db.products.findOne({ _id: 105 }, { recentReviews: 1, reviewCount: 1, starTotal: 1 }){
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
{
_id: 105,
recentReviews: [ { by: 'Priya', stars: 4 } ],
reviewCount: 1,
starTotal: 4
}$slice: -3 keeps only the last three elements, so recentReviews can never grow past three, while the full review is also inserted into reviews by the application.
Pattern: computed
The $inc on reviewCount and starTotal above is the computed pattern: instead of counting all reviews every time a page loads, you update a summary whenever the data changes. The average rating is then just starTotal / reviewCount. The same idea gives each customer a totalSpent field updated when an order is delivered. Reads become cheap, at the cost of a little extra work on each write.
Pattern: bucket
Storing every page view as its own tiny document wastes space and index entries. The bucket pattern groups many small events into one document per time period, for example one document per product per day:
db.viewBuckets.updateOne(
{ productId: 105, day: '2026-10-30' },
{ $push: { views: { at: ISODate('2026-10-30T09:15:00Z'), customerId: 2 } }, $inc: { count: 1 } },
{ upsert: true }
)The upsert creates the day's bucket on the first view and appends to it afterwards. A bucket must have a natural size limit, such as one day of views for one product; for very busy items, bucket by hour instead. MongoDB's time series collections apply this idea automatically for measurement data.
More patterns in brief
- Attribute: products of different kinds have different specifications. Storing them as an array such as
attributes: [{ k: 'capacity', v: '1TB' }, { k: 'port', v: 'USB-C' }]lets one index on{ 'attributes.k': 1, 'attributes.v': 1 }serve searches on any attribute. - Outlier: if one celebrity product gets 50,000 reviews while others get ten, design for the normal case and give the outlier a flag such as
hasExtraReviews: true, with the overflow stored in additional documents. - Schema versioning: when a document shape changes, add a field such as
schemaVersion: 2. The application handles both versions and upgrades old documents gradually, so you never need downtime for a migration.
Common mistakes
- Unbounded arrays. Pushing every order, log line or follower into one document eventually hits the 16 MB limit and makes every update slower long before that.
- Normalising everything. One collection per SQL table, joined on every page, throws away MongoDB's main advantage.
- Embedding shared data. Copying the whole product into every order means a description change must touch thousands of orders. Copy only a few stable fields.
- Designing without the queries. List your application's most frequent reads and writes first; the schema follows from them.
Key points
- Design for your application's access patterns: data read together is stored together.
- Embed one-to-one and one-to-few data that the parent owns; reference one-to-many and unbounded relationships from the child side.
- Never let an array grow without limit; a document can be at most 16 MB.
- Extended reference copies a few fields of a related document to avoid lookups.
- Subset embeds only the most-used part of a large related set; computed keeps running totals updated on each write.
- Bucket groups many small events into one document per period; attribute, outlier and versioning patterns handle varied, uneven and changing data.
Exercise
Design documents for a food delivery app in Pune: restaurants, their menus (20 to 80 dishes each), customers, orders and live delivery-partner GPS positions sent every 5 seconds. For each relationship, decide whether to embed or reference, and name any pattern you would use. Write one example order document.
Show solution
{
"_id": 50001,
"customerId": 311,
"restaurant": { "id": 77, "name": "Shree Krishna Bhojanalaya", "area": "Kothrud" },
"items": [
{ "dishId": 12, "name": "Misal Pav", "price": 120, "qty": 2 },
{ "dishId": 31, "name": "Sol Kadhi", "price": 60, "qty": 1 }
],
"total": 300,
"status": "out-for-delivery",
"placedAt": "2026-10-30T13:05:00Z"
}The menu is bounded and always shown with its restaurant, so it is embedded in the restaurant document. Customers and orders are one-to-many, so each order references customerId. Each order uses an extended reference to the restaurant (id, name, area) and copies dish names and prices, which must not change after ordering. GPS positions are one-to-squillions, so they go into their own collection, bucketed per delivery (or per minute), never into the order. (In a real database placedAt would be a date value, not a string.)
Quick Quiz
1. A blog post has 0 to 10 tags that are always shown with the post. What is the usual design?
Few, bounded values that are read with their parent should be embedded.
2. Why does each shop order store customerId instead of being embedded in the customer?
One-to-many relationships that keep growing are referenced from the child side to avoid huge documents.
3. What does $slice: -3 do inside a $push?
With $each, $slice: -3 trims the array to its three most recent elements.
4. Which pattern stores a running reviewCount updated on each new review?
The computed pattern keeps summary values up to date on write so reads are cheap.
Learn MongoDB with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
