MongoDB $lookup Stage

Beginner
6 min read
Updated: Oct 2026
4 examples
Live preview

What Is $lookup?

$lookup is MongoDB’s join stage. It pulls related documents from another collection into an array on each input document—like a SQL LEFT OUTER JOIN for orders and customers.

Remember
$lookup = "join this doc to related docs in another collection"

{
  $lookup: {
    from: "customers",
    localField: "customerId",
    foreignField: "_id",
    as: "customerInfo"
  }
}

from         → foreign collection name
localField   → field on input docs
foreignField → field in from collection
as           → name of the result array

no match → as: []
one match → as: [ { … } ]
many matches → as: [ { … }, { … } ]

flatten array → $unwind
recursive hops → $graphLookup
UsePrefer
One-level join (orders → customer)$lookup
Flatten a single match$unwind
Recursive hierarchy (org chart)$graphLookup

How to Use $lookup in MongoDB

Pick equality join for simple keys, or pipeline join when you need filters inside the join.

  1. Optional: $match early to shrink input documents.
  2. Add $lookup with from, localField, foreignField, and as.
  3. Optional: $unwind the as array for one-to-one shapes.
  4. Optional: $project to keep only the fields you need.
FieldRole
fromForeign collection to join
localFieldField on each input document
foreignFieldField in from to match against
asOutput array field name
let + pipelineCorrelated / filtered join form

Pseudocode

Pseudocode
for each input document:
  matches = find in "from" where
            foreignField == input.localField

  // pipeline form instead:
  // matches = run sub-pipeline on "from"
  //           using let vars ($$name) in $expr

  output = input + { as: matches }

// left outer join: input always kept
// empty matches → as: []

Cheat Sheet

Keep these facts handy when joining collections.

TopicDetail
TypeAggregation stage (left outer join)
Result shapeAlways an array in as
No matchas: [] (input doc still kept)
Equality formlocalField + foreignField
Pipeline formlet + pipeline + $expr
Flatten$unwind on the as field
Index tipIndex foreignField in from
vs $graphLookupOne hop vs recursive hops

Live Preview

Join sample orders to customers (teaching model—not a live MongoDB connection).

Join: orders.customerId = customers._id → field customerInfo.

Result
A100 → Acme [1 match]
B200 → Beta [1 match]
C300 → (none) [0 matches]

Worked Walkthrough — Orders → Customers

Goal: attach customer details to each order using customerId → _id.

Calculation
orders:
  { _id: "A100", customerId: 1, total: 40 }
  { _id: "B200", customerId: 2, total: 15 }
  { _id: "C300", customerId: 9, total: 99 }

customers:
  { _id: 1, name: "Acme" }
  { _id: 2, name: "Beta" }

$lookup localField: customerId, foreignField: _id
  A100 → customerInfo: [ { _id: 1, name: "Acme" } ]
  B200 → customerInfo: [ { _id: 2, name: "Beta" } ]
  C300 → customerInfo: []   ← left join keeps the order

MongoDB Examples

We use small orders and customers collections. Seed them once in Example 1, then run each join pipeline on that data. Copy each block into mongosh.

Example 1 — Sample Data

Imagine three orders pointing at two customers (plus one orphan customerId). Customers include an active flag for the pipeline join in Example 4.

mongosh
db.customers.drop()
db.orders.drop()

db.customers.insertMany([
  { _id: 1, name: "Acme", city: "Austin", active: true },
  { _id: 2, name: "Beta", city: "Boston", active: false }
])

db.orders.insertMany([
  { _id: "A100", customerId: 1, total: 40 },
  { _id: "B200", customerId: 2, total: 15 },
  { _id: "C300", customerId: 9, total: 99 }
])

How the Data Is Stored in MongoDB

After the inserts, MongoDB keeps two collections. Run find() on each to see what you stored:

mongosh
db.customers.find()
db.orders.find()

Read the join keys like this:

Collection / fieldExamplePlain English
orders.customerId1, 2, 9Foreign key on each order (local side of the join)
customers._id1, 2Customer id matched by $lookup (foreign side)
orders._id"A100"Order id (not used for the join in these examples)
customers.activetrue / falseFilter flag for pipeline $lookup in Example 4
Picture
Join key (equality $lookup):

  orders.customerId  ──matches──►  customers._id

  A100  customerId 1  →  Acme   (_id 1)
  B200  customerId 2  →  Beta   (_id 2)
  C300  customerId 9  →  (no customer _id 9)

How It Works

  1. Reset collections. drop() clears old tutorial data from both sides.
  2. Insert customers. Each row has _id, name, city, and active.
  3. Insert orders. Each order stores customerId pointing at a customer _id.
  4. find() confirms shape. Same documents you inserted—ready for $lookup in Examples 2–4.

Example 2 — Equality Join

Goal: join each order from Example 1 to its customer using matching keys.

mongosh
db.orders.aggregate([
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customerInfo"
    }
  }
])

How It Works

  1. Same data as Example 1. Orders hold customerId; customers use _id.
  2. Equality join. MongoDB matches customerId to _id.
  3. Array result. Matches land in customerInfo—even if there is only one.
  4. Left join. Order C300 stays with an empty array (no customer 9).

Example 3 — Flatten with $unwind

Goal: turn the joined array into a single nested object for API-friendly output. Keep unmatched orders with preserveNullAndEmptyArrays. Uses the orders and customers from Example 1.

mongosh
db.orders.aggregate([
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customerInfo"
    }
  },
  {
    $unwind: {
      path: "$customerInfo",
      preserveNullAndEmptyArrays: true
    }
  },
  {
    $project: {
      _id: 1,
      total: 1,
      customer: "$customerInfo.name",
      city: "$customerInfo.city"
    }
  }
])

How It Works

  1. Join first. Same equality $lookup as Example 2.
  2. Unwind. Array → single object (or missing field when empty).
  3. Preserve empties. Without preserve, C300 would disappear.
  4. Project. Lift name / city into flat fields for responses.

Example 4 — Pipeline + let

Goal: join only active customers and return a slim projection inside the join. Customer active flags come from Example 1 (Acme active, Beta inactive).

mongosh
db.orders.aggregate([
  {
    $lookup: {
      from: "customers",
      let: { cid: "$customerId" },
      pipeline: [
        {
          $match: {
            $expr: {
              $and: [
                { $eq: ["$_id", "$$cid"] },
                { $eq: ["$active", true] }
              ]
            }
          }
        },
        { $project: { _id: 0, name: 1, city: 1 } }
      ],
      as: "customerInfo"
    }
  }
])

How It Works

  1. let. cid captures each order’s customerId.
  2. $expr. Compare foreign _id to $$cid (double $ = let var).
  3. Extra filter. Only active: true customers join.
  4. Project early. Sub-pipeline returns slim docs—less data over the wire.

Edge Cases & Pitfalls

Check these before shipping a join-heavy pipeline.

array

Result is always an array

Even one match is [{ … }]. Read customerInfo[0] or $unwind if you need a single object.

types

Types must match

customerId: "1" does not equal _id: 1. Align types (ObjectId, number, string) on both sides.

unwind

$unwind drops empty joins by default

Use preserveNullAndEmptyArrays: true to keep left-join rows with no match.

scope

Same database only

from must be a collection in the same database as the input (unless using advanced variants your deployment supports).

Performance Notes

ApproachCostBest for
$match then $lookupFewer input docsMost pipelines
Index on foreignFieldFaster foreign scansLarge from collections
Pipeline $lookup + early $matchSmaller joined payloadsFiltered / projected joins
Nested multi-hop $lookupCan explodePrefer $graphLookup for trees

Join less data: filter early, project inside the sub-pipeline, and index the join key.

Key Takeaways

  • Role: left outer join between collections in aggregation.
  • Shape: matches always land in an as array.
  • Forms: equality keys, or let + pipeline for filters.
  • Trees: use $graphLookup for recursive relationships.

One line: $lookup answers “for each document, which related docs from another collection belong with it?”

Frequently Asked Questions

$lookup performs a left outer join. For each input document, MongoDB finds matching documents in the from collection and stores them in an array field named by as.
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customerInfo" } }. from is the foreign collection; as is the output array field.
$lookup can match zero, one, or many foreign documents. Even a single match lands in an array. Use $unwind to flatten when you expect at most one match.
Instead of localField/foreignField, pass let variables and a pipeline: use $match with $expr and $$varName to correlate. Enables filters and $project inside the join.
$lookup joins one level. $graphLookup recursively walks relationships across multiple hops. Use $lookup for orders→customer; use $graphLookup for org charts.
$match before $lookup to shrink input, index foreignField, filter early inside a pipeline $lookup, and avoid unnecessary $unwind on large arrays.

Next: $match

Filter documents early so later stages—including joins—work on less data.

$match tutorial →

About the author

Mari Selvan M P
Mari Selvan M P 🔗

Developer, cloud engineer, and technical writer

  • Experience 12 years building web and cloud systems
  • Focus Full Stack Development, AWS, and Developer Education

I write practical tutorials so students and working developers can learn by doing—from databases and APIs to deployment on AWS.

11 people found this page helpful