$lookup joins and $unwind

MongoDB · Advanced · 12 min

Referenced data has to be combined somewhere, and doing it in the pipeline avoids N+1 queries from the app.

$lookup performs a left outer join: for each input document it finds matching documents in another collection of the same database and puts them in an array field.

The simple form matches one field to another: { $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } }. The pipeline form adds let variables and a sub-pipeline for richer conditions, filtering or projecting the joined documents. Since MongoDB 5.0 you can combine localField/foreignField with a pipeline.

Because the result is always an array (empty when nothing matches), you usually follow with $unwind or take the first element with $first. $unwind: "$items" emits one document per array element; documents with an empty or missing array are dropped unless you pass preserveNullAndEmptyArrays: true.

$unwind then $group is the standard way to aggregate over array elements, such as revenue per SKU across all orders' line items.

Performance: each input document triggers a lookup, so index the foreignField (the _id already is). Filter with $match before $lookup to join fewer documents. If a page needs $lookup on every request, consider whether the data should be embedded or partially duplicated instead.

Example

db.customers.insertMany([ { _id: 1, name: "Ana" }, { _id: 2, name: "Ben" } ])
db.orders.insertMany([
  { _id: 10, customerId: 1, items: [ { sku: "PEN", qty: 3 }, { sku: "PAD", qty: 1 } ] },
  { _id: 11, customerId: 1, items: [ { sku: "PEN", qty: 2 } ] }
])
db.orders.aggregate([
  { $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "c" } },
  { $set: { customer: { $first: "$c.name" } } },
  { $unwind: "$items" },
  { $group: { _id: { customer: "$customer", sku: "$items.sku" }, qty: { $sum: "$items.qty" } } },
  { $sort: { qty: -1 } }
])
// [ { _id: { customer: 'Ana', sku: 'PEN' }, qty: 5 },
//   { _id: { customer: 'Ana', sku: 'PAD' }, qty: 1 } ]

Remember

  • $lookup is a left outer join that yields an array field
  • $unwind emits one document per array element
  • $unwind + $group aggregates across array elements
  • Index foreignField and $match before joining

Watch out for

  • $unwind silently dropping documents with empty arrays; use preserveNullAndEmptyArrays when you need them.

Check yourself

  1. An order has items: [] and the pipeline runs { $unwind: "$items" }. What happens to that order?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.mongodb.com/docs/manual/reference/operator/aggregation/lookup/

Builds on

Unlocks

Nothing builds on this yet.