Joins with $lookup

Combine data from several collections with $lookup: match local and foreign fields, flatten single matches with $unwind or $first, filter and shape joined documents with pipeline lookups, join arrays of ids, and keep joins fast with indexes.

Lesson 14 of 43Intermediate19 min read

MongoDB encourages you to embed related data so that most reads need just one document. But some data is better stored separately, as the references lesson explained: reviews that grow without limit, categories shared by many products, customers referenced by many orders. When a page or report needs that data together, $lookup joins it inside the aggregation pipeline.

$lookup takes each document in the pipeline, finds matching documents in another collection, and adds them as an array field. It behaves like a SQL left outer join: documents with no matches are kept, with an empty array. Combined with $unwind, $group and $project, it can answer questions such as "orders with their customer's name", "products with their average rating" or "customers who have never ordered".

In this lesson you'll use the simple form with localField and foreignField, flatten single matches, join arrays of ids, write pipeline lookups with extra conditions, and make joins fast. The examples use these collections:

MongoDB Shell
db.customers.insertMany([
  { _id: 'c1', name: 'Ada', city: 'Leeds' },
  { _id: 'c2', name: 'Ben', city: 'York' },
  { _id: 'c3', name: 'Cy', city: 'Leeds' }
])
db.orders.insertMany([
  { _id: 101, customerId: 'c1', total: 59.9, status: 'paid' },
  { _id: 102, customerId: 'c2', total: 12.5, status: 'paid' },
  { _id: 103, customerId: 'c1', total: 30, status: 'refunded' }
])

The basic $lookup

MongoDB Shell
db.orders.aggregate([
  { $lookup: {
      from: 'customers',          // the other collection
      localField: 'customerId',   // field in orders
      foreignField: '_id',        // field in customers
      as: 'customer'              // name of the new array field
  } }
])
  • The result always adds an array, even when only one document matches.
  • To turn a single match into a plain field, follow with $unwind: '$customer', or use $set: { customer: { $first: '$customer' } }.
  • Documents without matches get an empty array, so they're kept, like a left outer join. Filter them out with { $match: { customer: { $ne: [] } } } if you only want matches.
  • If localField is an array of ids, $lookup matches every id in it.

Pipeline lookups

For extra conditions, sorting, limits or projections on the joined documents, use let and pipeline:

MongoDB Shell
{ $lookup: {
    from: 'orders',
    localField: '_id',
    foreignField: 'customerId',
    pipeline: [
      { $match: { status: 'paid' } },
      { $sort: { total: -1 } },
      { $project: { _id: 0, total: 1 } }
    ],
    as: 'paidOrders'
} }

You can combine localField/foreignField with a pipeline (MongoDB 5.0+), or use let to pass variables into the pipeline and compare them with $expr for more complex matches.

Performance

  • Index the foreignField in the joined collection. Without an index, every lookup scans the whole collection, once for every input document.
  • Put $match, $sort and $limit before $lookup, so only the documents you'll actually show are joined.
  • Return only needed fields from the joined collection with a pipeline and $project.
  • $graphLookup follows references recursively, for hierarchies such as categories or an organisation chart.

MongoDB Aggregation Syntax

$lookup forms

{ $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } }
{ $unwind: '$user' }
{ $set: { user: { $first: '$user' } } }
{ $lookup: { from: 'tags', localField: 'tagIds', foreignField: '_id', as: 'tags' } }
{ $lookup: { from: 'orders', localField: '_id', foreignField: 'customerId',
             pipeline: [{ $match: { status: 'paid' } }, { $count: 'n' }], as: 'paid' } }
{ $match: { orders: { $size: 0 } } }   // no matches
db.orders.createIndex({ customerId: 1 })

MongoDB Aggregation Summary

  • $lookup joins another collection, adding matches as an array field named by as.
  • localField and foreignField match values; arrays of ids match each element.
  • Unmatched documents are kept with an empty array, like a left outer join.
  • Use $unwind or $first to turn a single match into a field; pipeline lookups add filters and projections.
  • Index foreignField and filter or limit before $lookup to keep joins fast.

MongoDB Aggregation Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Orders with their customer

MongoDB Shell
db.orders.aggregate([
  { $match: { status: 'paid' } },
  { $lookup: { from: 'customers', localField: 'customerId', foreignField: '_id', as: 'customer' } },
  { $unwind: '$customer' },
  { $project: { _id: 1, total: 1, name: '$customer.name', city: '$customer.city' } }
])
Output
[
  { _id: 101, total: 59.9, name: 'Ada', city: 'Leeds' },
  { _id: 102, total: 12.5, name: 'Ben', city: 'York' }
]
Line-by-line explanation
  1. Line 2Filtering first means only paid orders are joined.
  2. Line 3Each order gets a customer array holding the matching customer document.
  3. Line 4$unwind turns the one-element array into an embedded document.
  4. Line 5$project picks fields from the joined customer.
Example 2

Customers with their order totals, including those with none

MongoDB Shell
db.orders.createIndex({ customerId: 1 })
db.customers.aggregate([
  { $lookup: { from: 'orders', localField: '_id', foreignField: 'customerId', as: 'orders' } },
  { $project: {
      _id: 0,
      name: 1,
      orderCount: { $size: '$orders' },
      spent: { $sum: '$orders.total' }
  } },
  { $sort: { spent: -1 } }
])
db.customers.aggregate([
  { $lookup: { from: 'orders', localField: '_id', foreignField: 'customerId', as: 'orders' } },
  { $match: { orders: { $size: 0 } } },
  { $project: { _id: 0, name: 1 } }
])
Output
customerId_1
[
  { name: 'Ada', orderCount: 2, spent: 89.9 },
  { name: 'Ben', orderCount: 1, spent: 12.5 },
  { name: 'Cy', orderCount: 0, spent: 0 }
]
[ { name: 'Cy' } ]
Line-by-line explanation
  1. Line 1An index on the foreign field makes each lookup fast.
  2. Line 3Each customer gets the array of their orders.
  3. Lines 7 to 8$size counts the orders, and $sum adds up totals across the array.
  4. Line 13Cy has no orders: the join keeps him with an empty array, which $size: 0 finds.
Example 3

A pipeline lookup with conditions

MongoDB Shell
db.customers.aggregate([
  { $match: { city: 'Leeds' } },
  { $lookup: {
      from: 'orders',
      localField: '_id',
      foreignField: 'customerId',
      pipeline: [
        { $match: { status: 'paid' } },
        { $project: { _id: 0, order: '$_id', total: 1 } }
      ],
      as: 'paidOrders'
  } },
  { $project: { _id: 0, name: 1, paidOrders: 1 } }
])
Output
[
  { name: 'Ada', paidOrders: [ { total: 59.9, order: 101 } ] },
  { name: 'Cy', paidOrders: [] }
]
Line-by-line explanation
  1. Line 2Only customers in Leeds are joined.
  2. Lines 7 to 10The pipeline runs inside the joined collection: only paid orders, with just the fields needed.
  3. Line 8Ada's refunded order 103 is left out by the extra condition.
  4. Line 13Cy still appears with an empty array.

Common MongoDB Aggregation Mistakes

Expecting a single object instead of an array

Don't
{ $project: { name: '$customer.name' } }   // after $lookup alone, this is an array: [ 'Ada' ]
Do
{ $unwind: '$customer' }, { $project: { name: '$customer.name' } }

No index on the foreign field

Don't
// lookup from 100,000 customers into 5 million unindexed orders: a full scan per customer
Do
db.orders.createIndex({ customerId: 1 })

Joining before filtering

Don't
[{ $lookup: { from: 'customers', /* ... */ } }, { $match: { status: 'paid' } }, { $limit: 10 }]
Do
[{ $match: { status: 'paid' } }, { $limit: 10 }, { $lookup: { from: 'customers', /* ... */ } }]

MongoDB Aggregation in Real Projects

Joins in MongoDB applications

Admin panels use $lookup to show orders with customer details, or products with their supplier. Reporting pipelines join reviews to products to compute average ratings, and data exports combine several collections into one file. Mongoose's populate() does a similar job in application code. Teams watch for lookups on busy pages and move to embedding or extended references when performance matters.

MongoDB Aggregation FAQ

How do I join two collections in MongoDB?

Use the $lookup stage in an aggregation pipeline, naming the other collection in from, the field in the current documents in localField, the matching field in the other collection in foreignField, and the output array in as. Each document gains an array of the matching documents. Index the foreignField so the join stays fast.

Why does $lookup return an array instead of a single object?

$lookup can't know in advance how many documents will match, so it always returns an array, even for a one-to-one relationship. To get a plain object, add { $unwind: '$customer' } afterwards, or { $set: { customer: { $first: '$customer' } } }. $unwind drops documents with no match unless you set preserveNullAndEmptyArrays: true.

Can $lookup join collections in different databases?

No, in a normal deployment the from collection must be in the same database as the collection you're aggregating. If data from two databases has to be combined regularly, consider moving the collections into one database, copying the needed fields, or combining the results in your application.

Is $lookup slow in MongoDB?

It can be fast or very slow, depending mostly on whether the foreignField is indexed and how many documents reach the stage. Each input document triggers a lookup, so filtering and limiting before $lookup and having an index on the joined field make a big difference. If a hot page needs a join on every request, embedding or copying the needed fields is often the better design.

What is the difference between $lookup and populate in Mongoose?

$lookup runs inside the database as one aggregation. Mongoose's populate runs a separate query for the referenced documents and stitches them into the results in your application. populate is convenient with Mongoose models, while $lookup lets you filter, sort or group on the joined data in a single round trip.

MongoDB Aggregation Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the stage name that joins another collection.

db.orders.aggregate([{ : { from: 'customers', localField: 'customerId', foreignField: '_id', as: 'customer' } }])
Multiple choiceeasy

What type is the field created by $lookup's as option?

Multiple choicemedium

What happens to a document with no matches in a $lookup?

Predict the outputmedium

Using the sample data, how many orders does Ada's lookup array contain?

db.customers.aggregate([{ $match: { _id: 'c1' } }, { $lookup: { from: 'orders', localField: '_id', foreignField: 'customerId', as: 'o' } }, { $project: { n: { $size: '$o' } } }])
Multiple choicehard

Why place $match and $limit before $lookup?

MongoDB Aggregation Quiz

5 questions to check what you learned.

  1. Which $lookup option names the other collection?

    • into
    • from
    • collection
    • join
  2. How do you turn a one-element lookup array into an embedded document?

    • $flatten
    • $group
    • $unwind, or $set with $first
    • $project only
  3. What should be indexed to make $lookup fast?

    • foreignField in the joined collection
    • localField in the input collection
    • The as field
    • Nothing
  4. What does a pipeline inside $lookup allow?

    • Joining three collections at once only
    • Writing results to disk
    • Updating the joined documents
    • Extra conditions, sorting and projections on the joined documents
  5. Which stage follows references recursively, such as a category tree?

    • $graphLookup
    • $lookup
    • $unwind
    • $facet