MongoDB $lookup Aggregation Joins
Learn to join MongoDB collections with $lookup in aggregation pipelines. This hands-on tutorial covers the syntax, practical examples, and edge cases, preparing you for the next lesson in the MongoDB track.
Focus: join collections with $lookup in aggregation
You've mastered inserting, querying, and updating documents in a single MongoDB collection. But real-world data rarely sits in one place. Orders reference customers, posts reference authors, and log entries reference users. The naive approach — fetching each related document with a separate query — explodes into N+1 round trips that grind your app to a halt. In this lesson, you'll learn how to join collections with $lookup in aggregation, a single pipeline stage that replaces dozens of manual lookups with one efficient operation. By the end, you'll be able to combine related data across collections with confidence, handling common pitfalls like nulls, arrays, and performance bottlenecks.
The problem this lesson solves
Imagine building an e-commerce dashboard that shows recent orders with the customer's name and email. Without a join, you'd loop over each order, fire a findOne() to the customers collection, and piece together the results in application code. With 100 orders, that's 101 queries — and the latency adds up quickly. Worse, if a customer is deleted, your application must decide whether to show a placeholder or skip the order entirely. This manual joining logic is error-prone, hard to maintain, and slow.
MongoDB's aggregation framework solves this with the $lookup stage. It performs an equality match between a local field and a foreign field, pulling matching documents into the result as an array. This is the MongoDB equivalent of a SQL LEFT JOIN, but it runs entirely on the database server. You eliminate the N+1 problem, reduce network traffic, and keep your application code clean and declarative.
Core concept / mental model
Think of $lookup as a phone directory lookup. You have a list of people (the input documents) and a phone book (the foreign collection). For each person, you look up their name in the book and attach all matching phone numbers to their record. If the name isn't in the book, you get an empty list — not an error.
In MongoDB terms:
- from: the collection to look into (the "phone book")
- localField: the field in the input documents to match (the "name" on the person)
- foreignField: the field in the
fromcollection to match against (the "name" in the phone book) - as: the output field name where the matched documents land (the "phone numbers" array)
The result is that each input document gains a new field — always an array — even if there are zero matches. This is a critical mental shift from SQL: you don't get flat rows; you get nested arrays. You'll often need to reshape that array with $unwind or $addFields to get a flat structure that matches your reporting needs.
Here's a word diagram of the flow:
orders (input) ---> $lookup ---> orders with 'customer' array
{ customerId: 1 } |
+---> customers (from)
{ _id: 1, name: 'Alice' }
How it works step by step
Step 1: Understand the collection structure
Before writing a $lookup, you must know exactly which field in your local documents corresponds to which field in the foreign collection. Typically, the local field is a reference ID (e.g., customerId) and the foreign field is _id or a business key like sku. If the foreign field is _id, you can use it directly; if it's a different field, make sure it has an index for performance.
Step 2: Write the $lookup stage
The syntax is straightforward. You place $lookup inside an array of aggregation stages:
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } }
This stage processes every document from the first collection (the one you run aggregate() on) and attaches an array of matching documents from customers to the field named customer.
Step 3: Handle the array output
Because the as field is always an array, you often need to transform it. If you expect at most one match (e.g., a customer reference), you can $unwind the array to flatten it. If you expect many matches (e.g., order items), you might keep the array and use $project to reshape it or $unwind if you need one output document per match.
Step 4: Optimize with indexes
The $lookup will be much faster if the foreign field is indexed. By default, _id is indexed, so lookups on _id are efficient. For lookups on other fields, create an index on the foreign collection's foreignField to avoid collection scans.
Hands-on walkthrough
Let's build a realistic example step by step. Assume you have two collections: orders and customers. Each order has a customerId field and a total field. Each customer has _id, name, and email.
First, create the test data:
// Setup: create sample data (run in mongosh)
db.orders.insertMany([
{ _id: 1, customerId: 101, total: 250, item: "Laptop" },
{ _id: 2, customerId: 102, total: 30, item: "Mouse" },
{ _id: 3, customerId: 103, total: 150, item: "Monitor" }
]);
db.customers.insertMany([
{ _id: 101, name: "Alice", email: "alice@example.com" },
{ _id: 102, name: "Bob", email: "bob@example.com" },
_id: 103, name: "Charlie", email: "charlie@example.com" }
]);
Now run a simple $lookup to attach customer information to each order:
// Basic $lookup — join orders with customers
db.orders.aggregate([
{
$lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customer"
}
}
]).pretty();
Expected output (first order):
{
"_id" : 1,
"customerId" : 101,
"total" : 250,
"item" : "Laptop",
"customer" : [
{
"_id" : 101,
"name" : "Alice",
"email" : "alice@example.com"
}
]
}
Notice the customer field is an array with one object. To make it a flat object, use $unwind:
// Flatten the joined array
db.orders.aggregate([
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
{ $unwind: "$customer" }
]).pretty();
Now each output document has customer as a single object instead of an array. If an order has no matching customer, $unwind will remove that order from the results — be careful, that's a lost data scenario. To preserve unmatched orders, use preserveNullAndEmptyArrays: true:
// Keep orders without a matching customer
db.orders.aggregate([
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
{ $unwind: { path: "$customer", preserveNullAndEmptyArrays: true } }
]).pretty();
Expected: all three orders appear, but the third order might show a null customer if you deliberately removed customer 103. In this example, all customers exist, so nothing is dropped.
Now combine $lookup with other stages to build a meaningful report. For instance, compute total sales per customer:
// Join, unwind, and group to compute per-customer totals
db.orders.aggregate([
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
{ $unwind: "$customer" },
{
$group: {
_id: "$customer.email",
totalSpent: { $sum: "$total" },
customerName: { $first: "$customer.name" }
}
},
{ $sort: { totalSpent: -1 } }
]).pretty();
Expected output (assuming Charlie also has an order):
{
"_id" : "alice@example.com",
"totalSpent" : 250,
"customerName" : "Alice"
}
{
"_id" : "bob@example.com",
"totalSpent" : 30,
"customerName" : "Bob"
}
{
"_id" : "charlie@example.com",
"totalSpent" : 150,
"customerName" : "Charlie"
}
Compare options / when to choose what
$lookup is not the only way to relate data in MongoDB. The traditional approach is to embed related documents inside a single document or to denormalize references. Each has trade-offs:
| Approach | When to Use | Pros | Cons |
|---|---|---|---|
$lookup |
When related data is in separate collections and you need it occasionally | Reduces duplication, maintains data consistency | Slower than embedded data, adds aggregation complexity |
| Embedded documents | When related data is always accessed together and doesn't change often | Fast reads, atomic updates | Data duplication, hard to update across many documents |
| Manual application-side joins | When you have a very small result set and can't use aggregation | Simple to implement, no database load | N+1 problem, poor performance at scale |
When to choose $lookup: Use it when you have a relational pattern like orders/customers, posts/authors, or events/users, and you need to produce reports or API responses that combine data from multiple collections. It's also ideal when you want to keep your database normalized to avoid update anomalies.
When to embed instead: If you always display a customer's name and email next to their order, consider storing that info directly in the order document. But beware: if the customer changes their email, you must update every order. $lookup keeps data consistent but adds a join cost.
Variations to consider:
- Use
$lookupwithpipelinesub-field for complex joins (e.g., filtering, multiple conditions) — see the next section. - For self-referencing collections (e.g., employees with manager ID), you can use
$lookupwithfrompointing to the same collection. - Alternative: use
$graphLookupfor recursive hierarchical data like organizational charts, but it's more complex and beyond this lesson.
Troubleshooting & edge cases
Problem 1: My $lookup returns an empty array
Check the field names. A typo in localField or foreignField will silently produce no matches. Also ensure the data types match (e.g., string vs ObjectId). If the foreign field is _id (ObjectId) and your local field is a string, the lookup will fail. Convert types with $toString or $toObjectId in a previous stage.
Problem 2: $unwind removes documents unexpectedly
When a document has no match, $unwind by default drops it. If you need those documents, add preserveNullAndEmptyArrays: true. Alternatively, you can use $addFields and $arrayElemAt to get the first element without dropping.
Problem 3: Performance is terrible after $lookup
Create an index on the foreign field. For lookups on _id, it's already indexed. For other fields, run db.customers.createIndex({ email: 1 }) if you're joining on email. Also, filter as early as possible in your pipeline with $match before $lookup to reduce the number of input documents.
Problem 4: $lookup with array fields
If your local field is an array (e.g., productIds), $lookup automatically matches each element and returns an array of all matching foreign documents. That's often what you want for many-to-many relationships.
What you learned & what's next
In this lesson, you learned how to join collections with $lookup in aggregation. You now understand the syntax and options, how to flatten results with $unwind, and how to combine $lookup with other stages like $group and $sort to build powerful reports. You also learned about the trade-off between joining and embedding, and you can troubleshoot common issues like field mismatches and performance bottlenecks.
You can now explain the core idea behind $lookup and apply it in a practical exercise — for instance, building a dashboard that shows recent orders with customer details or generating a sales report by customer.
Your next step in the MongoDB track is to explore $unwind in more depth — it's essential for restructuring arrays after joins and for working with arrays in general. Mastering $unwind will let you transform nested join results into the exact shape your application or reports need.
Practice recap
Try building a report that lists every order with the customer's email and total spent, sorted by total descending. Use the sample data from this lesson. If you feel adventurous, add a $match stage to only include orders over $100 before the lookup to see how performance and results change.
Common mistakes
- Ignoring data type mismatch between local and foreign fields (string vs ObjectId) causing zero matches
- Using $unwind without preserveNullAndEmptyArrays and losing documents that have no match
- Remembering to index the foreign field — forgetting leads to slow lookups on large collections
- Assuming $lookup returns a single object, but it always returns an array
Variations
- Use $lookup's pipeline parameter for complex joins with custom conditions and sub-pipelines
- For many-to-many relationships, let localField be an array and the $lookup automatically matches each element
- Consider $graphLookup for recursive hierarchical joins like organizational charts
Real-world use cases
- E-commerce dashboard showing orders with customer names and emails
- Social media app fetching posts with author details in a single aggregation
- Inventory system joining products with supplier information for reporting
Key takeaways
- $lookup performs an equality match between a local field and a foreign field to combine collections
- The result is always an array — use $unwind to flatten it or $arrayElemAt to get the first element
- Use preserveNullAndEmptyArrays to keep unmatched documents when unwinding
- Index the foreign field to avoid performance issues
- Filter early with $match to reduce input documents before $lookup
- Compare $lookup with embedded documents: join for consistency, embed for access speed
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.