Embedding vs Referencing
One of the most consequential decisions you’ll make when modeling data in MongoDB is whether related information should live inside a single document (embedding) or be split across documents that point to each other by _id (referencing). Unlike a relational database, where normalizing data to avoid duplication is the default and joins are cheap, MongoDB hands you this choice explicitly — and the right answer depends entirely on how your application reads and writes the data, not on abstract normalization rules. Get it right and your queries stay fast and simple. Get it wrong and you’ll either blow past the document size limit or pay for expensive joins on every request.
Overview: How Embedding and Referencing Work
A MongoDB document is a BSON object, and BSON objects can nest other objects and arrays directly inside them. Embedding means putting that related data — a subdocument or an array of subdocuments — right inside the parent document. Referencing means storing only an ObjectId (or another unique value) that points at a document living in a separate collection, and fetching that document with a second query or an aggregation $lookup stage.
Both approaches are “real” MongoDB, and most non-trivial applications use a mix of the two. The trade-offs come down to a few concrete mechanics:
- Atomicity and round trips. A single-document write in MongoDB is always atomic, and a single
findOneorfindagainst one document is one index seek and one disk read. If related data is embedded, reading or writing the whole picture takes one operation. If it’s referenced, you need a second query, or a$lookupstage that performs the join for you inside the aggregation pipeline. - Document size. Every BSON document has a hard 16MB size limit. Anything embedded — especially an array that keeps growing — counts against that limit for the document it lives in. An array with no natural ceiling (comments on a viral post, events in an activity log) is a liability if embedded directly.
- Duplication and staleness. Embedding copies data into every parent that needs it. If a product’s price changes and that price is embedded in a thousand past orders, do you want those old orders to change too? Usually not — but for a live “current stock level” shown on a product page, embedding a stale copy is a bug waiting to happen.
- No referential integrity. Unlike a foreign key in SQL, MongoDB does not enforce that a referenced
_idstill exists. Deleting a product leaves any order that referenced it with a “dangling”productIdunless your application (or a cleanup job) handles it.
The guiding heuristic is: data that is read together should usually be stored together. Ask how the data is queried and updated in your actual application, then let that answer drive the schema — not habits carried over from third-normal-form relational design.
A Quick Decision Table
| Signal | Favor Embedding | Favor Referencing |
|---|---|---|
| Relationship shape | One-to-one or one-to-few | One-to-many-thousands, or many-to-many |
| Growth | Bounded (a fixed or small number of items) | Unbounded or fast-growing |
| Access pattern | Almost always read together with the parent | Often read independently of the parent |
| Sharing | Owned by exactly one parent | Shared/referenced by many different parents |
| Update independence | Updated together with the parent | Updated on its own schedule, by a different process |
Syntax
There’s no special operator for “embed” — it’s just how you shape the document you insert. Referencing is also plain: you store an ObjectId and either query it directly or join it in with $lookup.
// Embedding: related data lives inside the parent document
// as a subdocument or an array of subdocuments
db.parentCollection.insertOne({
field: "value",
embeddedArray: [
{ subfield: "value1" },
{ subfield: "value2" }
]
});
// Referencing: store the related document's _id, query it separately
db.parentCollection.insertOne({
field: "value",
relatedId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1")
});
db.relatedCollection.findOne({ _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") });
// Or join at query time with $lookup in an aggregation pipeline
db.parentCollection.aggregate([
{
$lookup: {
from: "relatedCollection",
localField: "relatedId",
foreignField: "_id",
as: "relatedDocs"
}
}
]);
- embeddedArray — a plain field holding subdocuments; no separate collection or index needed to read it.
- relatedId — an application-level convention (any field name works) storing an
ObjectIdthat points at another collection’s_id. - from — the collection to join against.
- localField — the field on the input documents to match.
- foreignField — the field on the
fromcollection to match against (usually_id, which is indexed by default). - as — the name of the new array field holding matched documents.
Examples
Example 1: Embedding a bounded one-to-few relationship
A blog post and a small, fixed set of highlighted comments are naturally read together and the comment count here is expected to stay small — a textbook case for embedding.
db.posts.insertOne({
title: "Why Indexes Matter",
author: "Priya Shah",
body: "Indexes are the single biggest lever you have for query performance...",
tags: ["mongodb", "performance"],
comments: [
{ user: "dev_amit", text: "Great post, this cleared up ESR for me!", postedAt: new Date("2026-07-01") },
{ user: "codegal", text: "Any chance of a follow-up on compound indexes?", postedAt: new Date("2026-07-02") }
],
createdAt: new Date("2026-06-30")
});
{
acknowledged: true,
insertedId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1")
}
One insertOne call wrote the post and its comments together as a single BSON document. Reading the post back with a single findOne returns everything — title, body, and comments — in one round trip, with no join required.
Example 2: Referencing a shared, independently updated resource
A product catalog is different: the same product is referenced by thousands of orders, and its price and stock change independently of any one order. Embedding a full copy of the product into every order would duplicate data and let it go stale.
db.products.insertOne({
_id: ObjectId("64f1a2b3c4d5e6f7a8b9c0e2"),
name: "Wireless Mouse",
sku: "WM-100",
price: 19.99,
stock: 340
});
db.orders.insertOne({
customer: "Rahul Verma",
items: [
{ productId: ObjectId("64f1a2b3c4d5e6f7a8b9c0e2"), quantity: 2, priceAtPurchase: 19.99 }
],
status: "placed",
orderedAt: new Date("2026-08-01")
});
{ acknowledged: true, insertedId: ObjectId("64f1a2b3c4d5e6f7a8b9c0e2") }
{ acknowledged: true, insertedId: ObjectId("64f1a2b3c4d5e6f7a8b9c0f3") }
The order stores only productId. To display full product details alongside an order, join them at query time:
db.orders.aggregate([
{ $match: { customer: "Rahul Verma" } },
{
$lookup: {
from: "products",
localField: "items.productId",
foreignField: "_id",
as: "productDetails"
}
}
]);
[
{
customer: "Rahul Verma",
items: [ { productId: ObjectId("64f1a2b3c4d5e6f7a8b9c0e2"), quantity: 2, priceAtPurchase: 19.99 } ],
status: "placed",
orderedAt: ISODate("2026-08-01T00:00:00.000Z"),
productDetails: [
{ _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0e2"), name: "Wireless Mouse", sku: "WM-100", price: 19.99, stock: 340 }
]
}
]
The catalog stays as one canonical copy per product no matter how many orders reference it, and updating stock or price requires touching only the products collection.
Example 3: A hybrid — the extended reference pattern
Pure referencing has a cost: every time you list a customer’s order history, you’d need a $lookup just to show the product name. The extended reference pattern denormalizes a handful of frequently needed, rarely changing fields directly into the referencing document, while still keeping the _id reference for anything else.
db.orders.insertOne({
customer: "Anjali Mehta",
items: [
{
productId: ObjectId("64f1a2b3c4d5e6f7a8b9c0e2"),
name: "Wireless Mouse",
priceAtPurchase: 19.99,
quantity: 1
}
],
orderedAt: new Date("2026-08-02")
});
{ acknowledged: true, insertedId: ObjectId("64f1a2b3c4d5e6f7a8b9c0f4") }
Now an order history page can render product names and prices with zero $lookup calls. This also happens to be more correct: priceAtPurchase should reflect what the customer actually paid, not today’s price, so denormalizing it isn’t just an optimization — it’s the right data. productId is still there if you need to jump to live stock or current price.
How It Works Step by Step
For the embedded post in Example 1, findOne({ title: ... }) does one index lookup (or collection scan, if title isn’t indexed), reads the single BSON document off disk — comments array included — and deserializes it into one JavaScript object. There is exactly one I/O round trip regardless of how many comments exist, which is both the strength and the danger of embedding: you always pay for the whole array, even if you only wanted the first two comments.
For the $lookup in Example 2, the aggregation engine processes $match first, narrowing the working set of orders using an index if one exists on customer. For each surviving order, it takes the values in items.productId and performs an equality lookup against products, matching on foreignField — here _id, which is indexed automatically. This behaves like a SQL left outer join: orders with no matching product still come through, just with an empty productDetails array. If foreignField isn’t indexed, MongoDB falls back to scanning the entire from collection for every batch of input documents, which gets expensive fast on large collections — always index the field you join on.
Common Mistakes
Mistake 1: Embedding an unbounded array
Comments on a post look like a one-to-few relationship — until one post goes viral and accumulates 50,000 comments. The document keeps growing, writes get slower as MongoDB has to rewrite and relocate the growing document, and you’re forced to fetch the entire array even when the page only shows the first 20 comments.
// Anti-pattern: this array has no upper bound and will keep growing forever
db.posts.updateOne(
{ _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") },
{ $push: { comments: { user: "newUser", text: "Nice write-up!", postedAt: new Date() } } }
);
The fix is to reference comments in their own collection, keyed by postId, and paginate:
db.comments.insertOne({
postId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1"),
user: "newUser",
text: "Nice write-up!",
postedAt: new Date()
});
db.comments.find({ postId: ObjectId("64f1a2b3c4d5e6f7a8b9c0d1") })
.sort({ postedAt: -1 })
.limit(20);
Mistake 2: Over-referencing data that’s always read together
Splitting a user’s address into its own collection because “that’s how you’d normalize it in SQL” forces two round trips every time you need to render a profile page, for data that is 1:1, small, and never queried on its own.
// Anti-pattern: two round trips for data that's always displayed together
const user = db.users.findOne({ _id: ObjectId("64f1a2b3c4d5e6f7a8b9c0f1") });
const address = db.addresses.findOne({ userId: ObjectId("64f1a2b3c4d5e6f7a8b9c0f1") });
Since the address is small, bounded (one per user), and always shown with the user, embed it instead:
db.users.insertOne({
_id: ObjectId("64f1a2b3c4d5e6f7a8b9c0f1"),
name: "Meera Nair",
email: "meera@example.com",
address: {
line1: "12 MG Road",
city: "Bengaluru",
postalCode: "560001",
country: "IN"
}
});
Mistake 3: Comparing an ObjectId reference to a plain string
A reference id read from a URL parameter or request body arrives as a string, not an ObjectId. Querying with it directly silently returns nothing.
// Anti-pattern: req.query.id is a string like "64f1a2b3c4d5e6f7a8b9c0e2",
// not an ObjectId, so this find matches nothing
db.products.findOne({ _id: req.query.id });
db.products.findOne({ _id: new ObjectId(req.query.id) });
Best Practices
- Default to embedding for one-to-few relationships where the child data is always displayed with the parent and has a natural upper bound.
- Reference when related data is large, shared by many parents, updated independently, or can grow without bound.
- Use the extended reference pattern to denormalize a few frequently needed, rarely-changing fields (a name, a price at time of purchase) alongside a reference, avoiding a
$lookupon your most common read path. - Always index the
foreignFieldused in$lookupand the reference field used in follow-up queries. - Keep documents comfortably under the 16MB limit; if an array could grow unbounded, move it into its own collection instead of embedding it.
- Model around your application’s real read and write patterns, not around relational normalization habits carried over from SQL.
- Remember MongoDB never enforces referential integrity — plan for how your application handles a reference that points at a deleted document.
Practice Exercises
- You’re building a recipe app. Each recipe has ingredients (typically 5-20, never shared between recipes) and reviews (potentially thousands per popular recipe, each with a user and rating). For each of the two, decide embed vs. reference and justify it in one sentence.
- A
studentscollection needs to track whichcourseseach student is enrolled in, and a single popular course can have 50,000 enrolled students. Would you embed an array of course ids in each student document, an array of student ids in each course document, or use a separateenrollmentscollection referencing both? Explain what breaks with the first two options at that scale. - Given a
postsschema where every post document embeds a full copy of its author’s entire profile (bio, avatar, social links, and every past post title), rewrite it using the extended reference pattern — pick 2-3 author fields worth denormalizing into the post and keep the rest behind a reference.
Summary
- Embedding stores related data inside one document; referencing stores an
_idpointer to a document in another collection. - Choose based on how your application reads and writes the data together, not on relational normalization habits.
- Embed one-to-few, bounded data that’s always read with its parent; reference one-to-many/unbounded, shared, or independently updated data.
$lookupapproximates a SQL join inside the aggregation pipeline, but MongoDB never enforces referential integrity.- The extended reference pattern — embed a few key fields plus keep a reference — is a common, effective hybrid.
- Watch the 16MB document size limit; unbounded arrays are the most common cause of embedding gone wrong.
