Joins with $lookup and Flattening with $unwind

Intermediate
13 min

Joins with $lookup and Flattening with $unwind

Embedding keeps related data in one document, but referenced data — customers, products, authors — has to be joined at read time. $lookup performs that join inside the aggregation pipeline, and $unwind turns array elements into separate documents so they can be grouped, filtered or joined individually. After this lesson you will be able to write equality and correlated $lookup stages, unwind arrays without losing documents, and know when a join is the wrong design.

Simple Equality Joins

The basic form matches a local field against a field of another collection in the same database and stores every match in a new array field:

javascript
db.orders.aggregate([ { $lookup: { from: "customers", // collection to join localField: "customerId", // field in orders foreignField: "_id", // field in customers as: "customer" // output array } } ]) // { _id: 1, customerId: 42, customer: [ { _id: 42, name: "Ada", ... } ] }

as is always an array, even for a one-to-one relation, and it is empty when nothing matches. If localField is an array, each element is matched, which makes $lookup work for tags: ["a", "b"] against a tags collection. The types must match exactly: a string customerId never joins an ObjectId _id.

Unwinding Arrays

$unwind emits one document per array element, copying the other fields:

javascript
{ $unwind: "$items" } // Long form { $unwind: { path: "$items", includeArrayIndex: "pos", preserveNullAndEmptyArrays: true } }

By default documents whose array is missing, null or empty are dropped. preserveNullAndEmptyArrays: true keeps them with the field absent, which matters when an order without items must still appear in a report. includeArrayIndex records the original position.

$unwind followed by $group is the classic way to aggregate array contents:

javascript
db.orders.aggregate([ { $unwind: "$items" }, { $group: { _id: "$items.productId", units: { $sum: "$items.qty" } } }, { $sort: { units: -1 } }, { $limit: 5 } ])

Taking a Single Match Without $unwind

Many tutorials follow $lookup with $unwind to turn the one-element array into an object. That silently drops documents with no match. For one-to-one references prefer an expression:

javascript
{ $set: { customer: { $first: "$customer" } } } // null when unmatched, document kept

Use $unwind on the joined array only when several matches are expected and each should become its own document.

Correlated Joins with let and pipeline

The pipeline form runs a sub-pipeline per input document. Variables declared in let are referenced as $$name inside $expr, and the sub-pipeline can filter, project, sort and limit:

javascript
db.customers.aggregate([ { $lookup: { from: "orders", let: { cid: "$_id" }, pipeline: [ { $match: { $expr: { $eq: ["$customerId", "$$cid"] }, status: "paid" } }, { $sort: { placedAt: -1 } }, { $limit: 3 }, { $project: { total: 1, placedAt: 1 } } ], as: "recentOrders" } } ])

Since MongoDB 5.0 you can combine both forms — localField/foreignField for the equality plus a pipeline for extra conditions — which lets the planner use an index on the foreign field while still filtering.

Performance and Design Notes

  • $lookup is a nested loop: for each input document it queries from. Index foreignField (the _id index already covers joins on _id).
  • Reduce the input first: $match and $project before $lookup, not after.
  • Joined data lives in memory per document; the 16 MB document limit applies to the result, so cap large joins with $limit inside the pipeline.
  • If a join runs on every page view, consider embedding or the extended reference pattern (copy the few fields you display) instead.
  • $graphLookup handles recursive relations such as org charts and threaded comments; $unionWith appends the documents of another collection rather than joining them.

Common Mistakes

  • Type mismatch between localField and foreignField produces empty arrays with no error.
  • $unwind after $lookup on optional relations, dropping every document without a match.
  • Joining an unindexed foreign field, turning a 10,000-document pipeline into 10,000 collection scans.
  • Expecting $lookup across databases. Both collections must be in the same database.
Quick Quiz
Question 1 of 3

After `$lookup` with `as: "customer"`, what is the type of `customer` when exactly one document matches?

Key Takeaways

  • $lookup joins collections in the same database; the result field is always an array and requires matching types.
  • Index the foreignField and shrink the input with $match and $project before joining.
  • $unwind creates one document per array element and drops empty or missing arrays unless preserveNullAndEmptyArrays is set.
  • For one-to-one references extract the match with $first rather than $unwind, so unmatched documents survive.
  • The let/pipeline form supports correlated sub-queries with filtering, sorting and limits; $graphLookup covers recursive relations.

Next lesson: Multi-Faceted Aggregation: $facet, $bucket, $out and $merge — compute several summaries in one pass, group values into ranges, and write pipeline results to collections.

Joins with $lookup and Flattening with $unwind - MongoDB | CodeYourCraft | CodeYourCraft