MongoDB $lookup Stage

Beginner
⏱️ 12 min read
📚 Updated: Jul 2026
🎯 5 Examples
Aggregation

What You’ll Learn

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

01

from

Foreign collection.

02

localField

Input side key.

03

foreignField

Match in from.

04

as array

Join output.

05

$unwind

Flatten rows.

06

pipeline

Advanced join.

Definition and Usage

$lookup is an aggregation stage that joins the current collection with documents from a foreign collection. MongoDB matches values between localField on the input document and foreignField in from, then attaches all matches to the field named by as.

💡
Beginner tip

After $lookup, the joined data is always an array—even for one-to-one relationships. Use $unwind to turn customerInfo: [{ name: "Acme" }] into a flat customerInfo: { name: "Acme" } field for API responses.

Use $lookup when related data lives in separate collections (normalized schema). For deeply nested or recursive relationships, consider $graphLookup. Filter input documents with $match before joining to reduce work.

📝 Syntax

$lookup supports equality joins and pipeline-based joins:

mongosh
{
  $lookup: {
    from: <foreign collection>,
    localField: <field on input docs>,
    foreignField: <field in from collection>,
    as: <output array field name>
  }
}
mongosh
{
  $lookup: {
    from: <foreign collection>,
    let: { <varName>: <expression> },
    pipeline: [ <stage1>, <stage2>, … ],
    as: <output array field name>
  }
}

Syntax Rules

  • from — required; collection name in the same database (not the full namespace path).
  • Equality form — requires localField, foreignField, and as.
  • Pipeline form — use let + pipeline; reference let vars as $$varName inside $expr.
  • Left outer join — input documents with no match get an empty as array [].
  • One-to-many — multiple foreign matches produce multiple array elements.
  • Index — create an index on foreignField for faster joins.
mongosh
db.orders.aggregate([
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
    }
  }
])

⚡ Quick Reference

QuestionAnswer
What it doesLeft outer join with a foreign collection
SQL equivalentLEFT JOIN (matches in array)
Output shapeArray field (as) on each input doc
Flatten 1:1 join$unwind: "$asField"
Optional join$unwind: { path: "$asField", preserveNullAndEmptyArrays: true }
Multi-hop trees$graphLookup, not nested $lookup loops
Basic join
localField:
  "customerId"
foreignField: "_id"

Equality

Flatten
{ $unwind:
  "$customer" }

1:1 shape

Correlated
let: { id: "$customerId" }
pipeline: [{ $match:
  { $expr: { $eq:
    ["$_id","$$id"] }}}]

Pipeline

Filter join
pipeline: [
  { $match: { active:
    true }} ]

In sub-pipe

🧰 Parameters

Key fields in the $lookup document:

from Required

Foreign collection to join against—must exist in the same database as the input collection.

from: "customers"
localField / foreignField Equality join

Match where foreignField in from equals localField on the input document.

localField: "customerId"
foreignField: "_id"
as Required

Name of the new array field added to each input document containing all matched foreign docs.

as: "customerDetails"
let / pipeline Advanced

Define variables from the input doc and run a sub-pipeline on from—enables $expr, extra $match, and $project.

let: { cid: "$customerId" }
pipeline: [ … ]

Examples Gallery

Sample orders and customers—basic equality join, flatten with $unwind, pipeline join with filters, correlated match, and comparison with $graphLookup.

📚 Getting Started

Customers and orders with foreign-key links.

Example 1 — Customers and orders collections

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

db.customers.insertMany([
  { _id: 1, name: "Acme Corp",   email: "billing@acme.com",   tier: "gold",   active: true },
  { _id: 2, name: "Beta LLC",    email: "ap@beta.com",        tier: "silver", active: true },
  { _id: 3, name: "Gamma Inc",   email: "finance@gamma.com",  tier: "bronze", active: false }
])

db.orders.insertMany([
  { _id: 101, customerId: 1, product: "Widget Pro", amount: 499, status: "shipped" },
  { _id: 102, customerId: 1, product: "Widget Lite", amount: 199, status: "shipped" },
  { _id: 103, customerId: 2, product: "Service Plan", amount: 89,  status: "pending" },
  { _id: 104, customerId: 99, product: "Orphan Order", amount: 50, status: "cancelled" }
])

db.customers.createIndex({ _id: 1 })
db.orders.createIndex({ customerId: 1 })

How It Works

Orders reference customers via customerId. Order 104 points to a missing customer (99)—useful for demonstrating left-outer-join behavior (empty array after lookup).

Example 2 — Join orders to customers (equality $lookup)

mongosh
db.orders.aggregate([
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
    }
  },
  {
    $project: {
      product: 1,
      amount: 1,
      customerId: 1,
      customer: 1,
      _id: 0
    }
  }
])

/* Order 101 → customer: [{ _id: 1, name: "Acme Corp", … }]
   Order 104 → customer: []  (no match — left outer join)
*/

How It Works

MongoDB scans the foreign collection for each input document (or uses an index on foreignField). This is the most common join pattern—equivalent to orders LEFT JOIN customers ON orders.customerId = customers._id.

Example 3 — Flatten with $unwind (one customer per order)

mongosh
db.orders.aggregate([
  {
    $lookup: {
      from: "customers",
      localField: "customerId",
      foreignField: "_id",
      as: "customer"
    }
  },
  {
    $unwind: {
      path: "$customer",
      preserveNullAndEmptyArrays: true
    }
  },
  {
    $project: {
      product: 1,
      amount: 1,
      customerName: "$customer.name",
      customerEmail: "$customer.email",
      customerTier: "$customer.tier",
      _id: 0
    }
  }
])

// preserveNullAndEmptyArrays: true → order 104 kept with null customer fields
// Without it → order 104 dropped (empty array can't unwind)

How It Works

$unwind converts the joined array into top-level fields. For strict inner-join behavior (drop unmatched orders), omit preserveNullAndEmptyArrays or set it to false.

📈 Practical Patterns

Pipeline joins, filters, and one-to-many relationships.

Example 4 — Pipeline $lookup (active customers only)

mongosh
db.orders.aggregate([
  {
    $lookup: {
      from: "customers",
      let: { custId: "$customerId" },
      pipeline: [
        {
          $match: {
            $expr: { $eq: ["$_id", "$$custId"] },
            active: true
          }
        },
        {
          $project: {
            name: 1,
            email: 1,
            tier: 1,
            _id: 0
          }
        }
      ],
      as: "customer"
    }
  },
  { $match: { customer: { $ne: [] } } },
  { $unwind: "$customer" },
  {
    $project: {
      product: 1,
      amount: 1,
      customerName: "$customer.name",
      tier: "$customer.tier",
      _id: 0
    }
  }
])

// Gamma Inc (active: false) excluded inside sub-pipeline
// $expr correlates $$custId with foreign _id

How It Works

Pipeline syntax runs a mini aggregation on the foreign collection per input document. Use it when you need extra filters, computed matches, or field projection inside the join—beyond simple field equality.

Example 5 — One-to-many join (customer → all orders)

mongosh
db.customers.aggregate([
  { $match: { active: true } },
  {
    $lookup: {
      from: "orders",
      localField: "_id",
      foreignField: "customerId",
      as: "orders"
    }
  },
  {
    $project: {
      name: 1,
      tier: 1,
      orderCount: { $size: "$orders" },
      totalSpent: { $sum: "$orders.amount" },
      orders: {
        $map: {
          input: "$orders",
          as: "o",
          in: { product: "$$o.product", amount: "$$o.amount" }
        }
      },
      _id: 0
    }
  }
])

// Acme Corp → orders: [101, 102] — array with TWO elements
// vs $graphLookup for recursive hierarchies (see graphlookup page)

// Tip: $match customers BEFORE $lookup to reduce join work

How It Works

Reversing the join direction (customers as input, orders as foreign) demonstrates one-to-many results naturally. Keep the array when the UI needs nested data; use $unwind when you need one row per order.

🧠 How $lookup Works

1

Input document

Each document from the prior stage enters $lookup—often already filtered by $match.

Input
2

Foreign match

MongoDB queries from where foreignField equals localField, or runs the sub-pipeline with let variables.

Join
3

Array attached

All matches are appended to the new as field—empty array if none found.

Output
=

Enriched documents

$unwind, $project, and $group downstream shape joined data for APIs or reports.

📝 Notes

  • from must be a collection name in the current database—not db.collection dot notation.
  • Joined data is always an array in the as field—use $unwind to flatten.
  • Index foreignField (and filter fields in pipeline joins) for production performance.
  • $lookup cannot join across different databases—collections must share the same database.
  • For unmatched rows, the array is []—a left outer join, not an inner join.
  • Previous topic: $listSessions. Next: $match.

Conclusion

$lookup brings SQL-style joins to aggregation: set from, localField, foreignField, and as, then $unwind when you need flat documents.

Reach for pipeline syntax when the join needs filters or computed matches. Filter early with $match. Next: $match to reduce documents before joins.

💡 Best Practices

✅ Do

  • Put $match before $lookup to join fewer documents
  • Index foreignField on the joined collection
  • Use pipeline $lookup to filter and project inside the join
  • Use $unwind with preserveNullAndEmptyArrays for optional relationships
  • Consider embedding related data when it is always read together

❌ Don’t

  • Assume as is a single object—it is always an array first
  • Run $lookup on full collections without filtering when datasets are large
  • Use $lookup for deep recursive trees—use $graphLookup
  • Forget that unmatched input docs still appear with as: []
  • Join across databases—normalize to one database or denormalize instead

Key Takeaways

Knowledge Unlocked

Five things to remember about $lookup

Use these points whenever you join collections in aggregation.

5
Core concepts
📦 02

as array

Always array.

Shape
📈 03

$unwind

Flatten 1:1.

Pattern
04

pipeline

let + $expr.

Advanced
🔍 05

$match first

Less join work.

Perf

❓ Frequently Asked Questions

$lookup performs a left outer join between the input collection and a foreign collection. For each input document, MongoDB finds matching documents in from where foreignField equals localField (or per a sub-pipeline) and stores matches in an array field named by as.
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customerInfo" } }. from is the foreign collection name; as is the output array field on each input document.
$lookup can match zero, one, or many foreign documents per input row—like a one-to-many join. 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 array: { $lookup: { from: "...", let: { id: "$customerId" }, pipeline: [{ $match: { $expr: { $eq: ["$_id", "$$id"] } } }], as: "..." } }. Enables filters, $project, and correlated conditions inside the join.
$lookup joins one level—each input document matches related foreign documents once. $graphLookup recursively walks relationships across multiple hops. Use $lookup for orders→customer; use $graphLookup for org charts or comment threads.
$match before $lookup to reduce input documents, index foreignField (and localField when filtering), use pipeline $lookup to filter early inside the sub-pipeline, and avoid unnecessary $unwind on large arrays.
Did you know?

Pipeline $lookup (with let and pipeline) was added in MongoDB 3.6—replacing workarounds that required multiple aggregation passes. MongoDB 5.1+ improved $lookup performance on sharded collections when the foreign collection is sharded. See the official $lookup docs.

Continue the Stages Series

Join with $lookup, then filter pipelines with $match.

Next: $match →

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.

5 people found this page helpful