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.
Fundamentals
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.
Foundation
📝 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>
}
}
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)
customer is always an ARRAY
Matched → one or more objects inside
No match → empty array []
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)
customer object (not array) per row
API-friendly flat documents
Use preserveNull for optional joins
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)
Filter active inside sub-pipeline
$project trims joined fields early
Inner-join effect via $match after lookup
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)
Acme → 2 orders in array
orderCount via $size
totalSpent via $sum on embedded array
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.
Compare
📋 $lookup vs related join approaches
Approach
Effect
Best for
$lookup (equality)
Single-level join by field match
Orders → customer, post → author
$lookup (pipeline)
Join + filter/project in sub-pipeline
Active-only joins, complex match rules
$graphLookup
Recursive multi-hop traversal
Org charts, nested comments
Embedded documents
Related data stored in same doc
Always-read-together data (no join needed)
Application-side join
Two queries + merge in code
Simple cases; loses pipeline expressiveness
🧠 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.
Important
📝 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.
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
Summary
Key Takeaways
Knowledge Unlocked
Five things to remember about $lookup
Use these points whenever you join collections in aggregation.
5
Core concepts
🔗01
Left join
Outer semantics.
Basics
📦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.