The Core Trade-off
Embedding places related data as a nested sub-document or array directly inside a parent document, so a single find() call retrieves everything needed to render a view. Referencing instead stores an _id (or array of _ids) pointing to documents in another collection, requiring a second query or a $lookup aggregation stage to assemble the full picture. Embedding trades some duplication and update complexity for read performance and atomicity — a single-document write is atomic in MongoDB, whereas coordinating writes across two referenced documents is not, absent a multi-document transaction. Referencing trades a bit of read latency for normalization, smaller documents, and independence between the two entities' lifecycles.
Cricket analogy: A scorecard that prints each batsman's full career stats inline (embedding) lets you read everything in one glance, versus a scorecard that just lists player IDs you'd have to look up separately (referencing) — the first is faster to read, the second stays lean and reusable across matches.
One-to-Few, One-to-Many, One-to-Squillions
MongoDB modeling literature commonly splits one-to-N relationships into three regimes. One-to-few (a handful of items, like a user's two or three addresses) is almost always embedded as a small array — there's little downside and it's fast to read. One-to-many (dozens to low-hundreds, like a product's reviews) is a judgment call: embed if the array stays bounded and is always read with the parent, reference if reviews are paginated independently or can grow large. One-to-squillions (unbounded, like a sensor's time-series readings or a user's activity log) should almost always be referenced, typically with the parent's _id stored on the child (parent-referencing), since embedding would blow past reasonable document sizes and slow every write to the parent.
Cricket analogy: A player's shirt number is a one-to-few relationship (one player, a handful of jersey numbers across career), easily embedded, while every ball he's ever faced is one-to-squillions and lives in a separate ball-by-ball database, not on his player card.
Using $lookup When You Do Reference
When data is referenced, the aggregation framework's $lookup stage performs a left outer join, matching a localField on the source collection against a foreignField on the target collection and producing an array of matched documents. This is the standard way to reassemble referenced data for a single query rather than issuing N+1 round trips from application code. $lookup is powerful but not free: it typically requires an index on the foreign field to avoid a full collection scan for every input document, and joining across sharded collections has additional restrictions since MongoDB 5.0's improvements around routing. For hot paths, many teams still prefer denormalizing a few frequently-needed fields (like a product's name and price) onto the referencing document to avoid a $lookup entirely.
Cricket analogy: A TV broadcast overlay that pulls a bowler's career economy rate live from a separate stats database, matching on the bowler's ID, is exactly what $lookup does — joining two feeds by a shared key at query time rather than pre-printing every stat on the ball-by-ball feed.
// Referencing: orders store only a customerId
db.orders.insertOne({ _id: ObjectId(), customerId: ObjectId("64f1..."), total: 249.99, items: [/* ... */] });
// Reassembling with $lookup instead of a second app-level query
db.orders.aggregate([
{ $match: { customerId: ObjectId("64f1...") } },
{
$lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customer"
}
},
{ $unwind: "$customer" },
{ $project: { total: 1, "customer.name": 1, "customer.tier": 1 } }
]);
// Requires an index for performance at scale
db.customers.createIndex({ _id: 1 }); // _id is indexed by default, but foreign fields often aren'tRule of thumb from the MongoDB manual's data modeling guidance: prefer embedding for one-to-few, prefer referencing for one-to-squillions, and treat one-to-many as a judgment call based on whether the child data is bounded and always consumed with the parent.
$lookup without an index on the foreign field forces a collection scan per matched document on the 'from' collection, which can silently turn a fast query into a slow one as data grows — always index the field you're joining on.
- Embedding co-locates related data for atomic, single-round-trip reads; referencing normalizes data across collections.
- Single-document writes are atomic in MongoDB; cross-document consistency needs care or explicit transactions.
- One-to-few relationships are almost always embedded; one-to-squillions relationships are almost always referenced.
- One-to-many is a judgment call based on boundedness and whether the child is always read with the parent.
- $lookup performs a left outer join across collections at query time and needs an index on the foreign field.
- Denormalizing a few hot fields onto the referencing document is a common way to avoid $lookup on critical paths.
- The right choice depends on the specific application's read/write patterns, not a universal rule.
Practice what you learned
1. What is the primary benefit of embedding related data in MongoDB?
2. Which relationship type should almost always be referenced rather than embedded?
3. What does the $lookup aggregation stage perform?
4. Why is indexing the foreign field important for $lookup performance?
5. What is a common alternative to running $lookup on every request for hot paths?
Was this page helpful?
You May Also Like
Schema Design in MongoDB
Learn how to design MongoDB document schemas around your application's access patterns rather than around abstract normalized tables.
Data Modeling Patterns
A tour of named MongoDB schema design patterns — attribute, bucket, computed, subset, and extended reference — and the problems each one solves.
Working with Arrays and Nested Documents
Master querying and updating deeply nested MongoDB documents and arrays using dot notation, positional operators, and array update operators.