$lookup Stage
$lookup Stage
Level 6 — Aggregation Framework The aggregation pipeline stage that executes a left outer join with another collection in the same database, serving as the direct equivalent of SQL's
LEFT JOINstatement.
1. Prerequisites
- Aggregation Pipeline (Concept) — Aggregation pipeline overview.
ObjectIdas a Manual Reference — ObjectId reference fields.- Embedding vs. Referencing — The relational data modeling context.
2. Term Category
Aggregation (Cross-Collection Left Outer Join Stage): The $lookup stage performs a left outer join to combine documents from an un-sharded collection with documents in the pipeline stream.
3. Explanation
Environment Context
- MongoDB Core (Executed on the database server. Joins are processed in the memory engine; using indexes on the target collection's join field is critical to prevent full-collection scans).
(1) Design Motivation — "Why did we design this?"
While document modeling encourages nesting (embedding) data, you must use Referencing for unbounded arrays and many-to-many relationships to avoid hitting the 16MB document limit.
When referencing, you occasionally need to combine data from different collections to compile reports:
- Displaying an order record alongside the buyer's user details.
- Listing products alongside their manufacturer details.
In PostgreSQL, you write a join:
SELECT * FROM orders LEFT JOIN customers ON orders.customer_id = customers.id;
We designed the $lookup stage in MongoDB to provide this join capability.
It queries a target collection, matches keys, and pulls in the related documents, allowing you to resolve relationships during queries.
(2) Basic $lookup Syntax
A basic $lookup stage requires 4 keys:
{
$lookup: {
from: "target_collection", // Collection to join with
localField: "local_key", // Key path in the source document
foreignField: "foreign_key", // Key path in the target collection
as: "output_array" // Field name to write the results into
}
}
- The Array Output Constraint: Even if the relationship is strict 1:1,
$lookupalways returns the joined documents as a BSON Array. - If no matches are found, it writes an empty array
[](behaving exactly like a SQL Left Outer Join, which preserves the parent row even if the child join is missing).
(3) Reality Metaphor (Filing Clerks)
Imagine an office clerk auditing files:
$lookupStage: The clerk holds an order folder containing acustomer_idstamp (localField).- They walk to a separate cabinet labeled
"Customers"(from). - They find the user file where the ID matches (
foreignField). - They make a photocopy of the customer file and place the copy inside a new Plastic Sleeve Sleeve labeled
"customer_info"(asarray) inside the order folder.
- They walk to a separate cabinet labeled
(4) Code Examples
Joining Orders to Customers
Let's link orders to their buyers:
db.orders.aggregate([
// Join orders with the customers collection
{
$lookup: {
from: "customers",
localField: "customer_id",
foreignField: "_id",
as: "customer_details"
}
}
]);
// Output:
// {
// "_id": 500,
// "amount": 99.99,
// "customer_id": ObjectId("60c72b2f9b1d8b2e88a8d111"),
// "customer_details": [ // Note: returned as an array!
// { "_id": ObjectId("..."), "name": "Alice Smith" }
// ]
// }
4. Common Mistakes & Pitfalls
Mistake 1: Forgetting that $lookup always returns an array, and failing to flatten it for 1:1 lookups
The mistake: Running a $lookup to join a customer to an order, and immediately trying to access their name in your backend template using order.customer_details.name (which returns undefined because customer_details is an array).
Why it's wrong: As shown in the Code Example output, $lookup writes an array [ { ... } ].
To access fields inside the object directly, you must flatten the array first.
Fix: Append an $unwind stage immediately after the $lookup stage to deconstruct the array into a flat subdocument object.
// CORRECT
db.orders.aggregate([
{
$lookup: {
from: "customers",
localField: "customer_id",
foreignField: "_id",
as: "customer_details"
}
},
// Flatten array to a single object:
{ $unwind: "$customer_details" }
]);
// Now customer_details is flat: { "customer_details": { "name": "Alice" } }
Mistake 2: Executing $lookup Left Outer Joins on Un-Indexed foreignField Keys
The mistake: Executing db.orders.aggregate([{ $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } }]) without an index on users._id.
Why it's wrong: Un-indexed $lookup operations execute a full collection scan on the joined collection FOR EVERY SINGLE DOCUMENT in the source pipeline, causing severe latency.
Incorrect:
// Executing $lookup without index on foreignField
Fix:
db.users.createIndex({ _id: 1 }); // Ensure foreignField is indexed!
Mistake 3: Expecting $lookup to Return Single Objects instead of Array Objects
The mistake: Expecting order.user.name directly after $lookup without unwinding.
Why it's wrong: $lookup ALWAYS returns an array of matched documents in the as field (even for 1-to-1 relationships). Use $unwind: "$user" or $arrayElemAt to convert array to single object.
Incorrect:
const name = doc.user.name; // ❌ doc.user is an array [{ name: 'Alice' }]!
Fix:
db.orders.aggregate([
{ $lookup: { from: "users", localField: "userId", foreignField: "_id", as: "user" } },
{ $unwind: "$user" }
]);
5. Practice Exercises
Exercise 1: Single-Field Un-Sharded Join with $lookup
Scenario:
Join collection orders with collection users on customerId = users._id to populate customer details.
Requirements:
- Use
$lookup: { from: "users", localField: "customerId", foreignField: "_id", as: "customer" }.
Answer
Implementation
db.orders.aggregate([
{
$lookup: {
from: "users",
localField: "customerId",
foreignField: "_id",
as: "customerDetails"
}
}
]);
Technical Explanation
$lookupperforms left outer equality joins against target collections.asspecifies the output array field where matched target documents are stored.- Requires an index on
foreignField(users._id) for fast join execution.
Exercise 2: Correlated Subqueries with Pipelines in $lookup
Scenario:
Join customers with orders, filtering joined orders to include ONLY those with total > 100 and status: "completed".
Requirements:
- Use
$lookupwithletand nestedpipeline.
Answer
Implementation
db.customers.aggregate([
{
$lookup: {
from: "orders",
let: { custId: "$_id" },
pipeline: [
{
$match: {
$expr: {
$and: [
{ $eq: ["$customerId", "$$custId"] },
{ $eq: ["$status", "completed"] },
{ $gt: ["$total", 100] }
]
}
}
}
],
as: "largeCompletedOrders"
}
}
]);
Technical Explanation
letbinds local pipeline fields to variables ($$custId).pipelineexecutes custom aggregation stages on target collection documents before joining.- Reduces joined payload size server-side.
Exercise 3: Unwrapping Joined Array Results with $unwind
Scenario:
Flatten the 1-element customerDetails array returned by $lookup into a single embedded subdocument.
Requirements:
- Chain
$unwind: "$customerDetails"after$lookup.
Answer
Implementation
db.orders.aggregate([
{
$lookup: {
from: "users",
localField: "customerId",
foreignField: "_id",
as: "customer"
}
},
{ $unwind: "$customer" }
]);
Technical Explanation
$lookupalways outputs matching documents inside an array field.$unwindtransforms 1-element arrays into direct embedded subdocument objects.- Simplifies downstream field access.
6. Related Terms
- Aggregation Pipeline (Concept) — The parent pipeline framework.
$unwindStage — The array flattening tool.- Aggregation Variables (
$$ROOT,$$CURRENT,$$NOW,let) — Related concept: Aggregation Variables ($$ROOT,$$CURRENT,$$NOW,let). $graphLookupStage — Related concept:$graphLookupStage.- Embedding vs. Referencing — Referencing documents.
7. Key Takeaways
$lookupperforms left outer joins between collections in a database.- Direct NoSQL equivalent to SQL's
LEFT JOINstatement. - Always returns matching documents as a BSON array inside the parent.
- If no matches are found, it outputs an empty array
[]without dropping the document. - Append
$unwindimmediately after$lookupto flatten 1:1 relations. - Ensure the target
foreignFieldhas an index to prevent slow collection scans. - Use advanced
$lookupwith sub-pipelines for complex conditional joins.