Learn how $lookup joins documents from two MongoDB collections, how to flatten the results, filter joined data with a sub-pipeline and keep joins fast.

Bishal Biswas
Self Learner
Before you start: this lesson assumes you’ve read Core Stages: match, project, group and sort. It uses the
shopdata from Aggregation Pipeline Fundamentals, so run that setup script first if you haven’t.
What you will learn
By the end of this lesson you will know:
- Why you sometimes need to join collections in MongoDB
- How the basic
$lookupstage works, and why it always returns an array - How to turn that array into a single embedded document
- How to filter and trim joined documents with a sub-pipeline
- How to keep joins fast with the right index
Why join at all?
In Embedding vs Referencing you learned that some data is better kept in its own collection and referenced. Our orders do exactly that: each order stores a customerId, not the whole customer.
// An order only knows the customer's _id
{ _id: 1, customerId: ObjectId("66a000000000000000000001"), total: 158, status: "delivered", ... }
// The customer lives in another collection
{ _id: ObjectId("66a000000000000000000001"), name: "Asha Roy", city: "Kolkata", ... }
That’s great for storage, but an invoice page needs both. You could run two queries from your app. Or you can ask the pipeline to do the matching for you with $lookup.
Think of it like this: Our conveyor belt carries orders. At one station, a worker reads the customer number on each order, walks to the customer filing cabinet, photocopies every matching card, and staples the copies to the order. The order now travels on with its customer attached.
Your first $lookup
The basic form takes four fields:
| Field | Meaning |
|---|---|
from |
The other collection to search (same database) |
localField |
The field in the documents on the belt |
foreignField |
The field in the from collection to compare with |
as |
The name of the new array field to put the matches in |
Attach the customer to order 1:
db.orders.aggregate([
{ $match: { _id: 1 } },
{ $lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customer"
} }
])
Result:
[
{
_id: 1,
customerId: ObjectId("66a000000000000000000001"),
status: "delivered",
createdAt: ISODate("2026-01-10T00:00:00.000Z"),
total: 158,
items: [ ... ],
customer: [
{
_id: ObjectId("66a000000000000000000001"),
name: "Asha Roy",
email: "asha@example.com",
city: "Kolkata",
joinedAt: ISODate("2025-01-15T00:00:00.000Z"),
isMember: true
}
]
}
]
Look closely: customer is an array, even though there is only one match. $lookup always produces an array, because in general there could be zero, one or many matches. The worker staples all the photocopies, even if there’s only one.
Turning the array into one document
For “one order has one customer”, an array of one is awkward. There are two common fixes.
Option 1: $first (array operator, MongoDB 4.4+) replaces the array with its first element:
db.orders.aggregate([
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
{ $set: { customer: { $first: "$customer" } } },
{ $project: { _id: 1, total: 1, customerName: "$customer.name", city: "$customer.city" } }
])
// → [
// { _id: 1, total: 158, customerName: "Asha Roy", city: "Kolkata" },
// { _id: 2, total: 1999, customerName: "Asha Roy", city: "Kolkata" },
// { _id: 3, total: 498, customerName: "Rahul Mehta", city: "Mumbai" },
// { _id: 4, total: 329, customerName: "Priya Nair", city: "Kolkata" },
// { _id: 5, total: 2248, customerName: "Ken Sato", city: "Tokyo" },
// { _id: 6, total: 245, customerName: "Priya Nair", city: "Kolkata" }
// ]
Option 2: $unwind splits the array into separate documents (you’ll study it in the next lesson). With one match, you get one document with customer as a plain object:
{ $unwind: "$customer" }
Warning: By default
$unwinddrops documents whose array is empty. If an order pointed to a deleted customer, it would quietly disappear from your report.$firstkeeps the order and just leavescustomermissing.
Joining, then grouping
Once the customer is attached, you can group by any of its fields. Revenue per city:
db.orders.aggregate([
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
{ $set: { customer: { $first: "$customer" } } },
{ $group: { _id: "$customer.city", revenue: { $sum: "$total" }, orders: { $sum: 1 } } },
{ $sort: { revenue: -1 } }
])
// → [
// { _id: "Kolkata", revenue: 2731, orders: 4 },
// { _id: "Tokyo", revenue: 2248, orders: 1 },
// { _id: "Mumbai", revenue: 498, orders: 1 }
// ]
The city only exists in customers, and the totals only exist in orders. The join lets one pipeline use both.
Joining the other way: one-to-many
Now start from customers and pull in their orders. This time many matches are normal:
db.customers.aggregate([
{ $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "orders" } },
{ $project: { _id: 0, name: 1, orderCount: { $size: "$orders" } } }
])
// → [
// { name: "Asha Roy", orderCount: 2 },
// { name: "Rahul Mehta", orderCount: 1 },
// { name: "Priya Nair", orderCount: 2 },
// { name: "Ken Sato", orderCount: 1 },
// { name: "Maria Garcia", orderCount: 0 }
// ]
Maria has no orders, so her orders array is empty, but she is still in the result. This is what SQL calls a left outer join: every document on the belt stays, matched or not.
-- The SQL idea, for comparison
SELECT c.name, COUNT(o.id)
FROM customers c LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.name;
Want only customers who have never ordered? Match on the empty array:
db.customers.aggregate([
{ $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "orders" } },
{ $match: { orders: { $size: 0 } } },
{ $project: { _id: 0, name: 1, email: 1 } }
])
// → [ { name: "Maria Garcia", email: "maria@example.com" } ]
That’s a perfect list for a “Your first order is 10% off!” email.
Filtering joined documents with a sub-pipeline
The basic $lookup copies every matching document, with every field. Sometimes you only want some of them. For that, $lookup accepts a pipeline: a little conveyor belt that runs inside the other collection.
Since MongoDB 5.0 you can combine localField/foreignField with a pipeline. Here each customer gets only their delivered orders, with only the total:
db.customers.aggregate([
{ $lookup: {
from: "orders",
localField: "_id",
foreignField: "customerId",
pipeline: [
{ $match: { status: "delivered" } },
{ $project: { _id: 1, total: 1 } }
],
as: "deliveredOrders"
} },
{ $project: { _id: 0, name: 1, deliveredOrders: 1 } }
])
// → [
// { name: "Asha Roy", deliveredOrders: [ { _id: 1, total: 158 } ] },
// { name: "Rahul Mehta", deliveredOrders: [ { _id: 3, total: 498 } ] },
// { name: "Priya Nair", deliveredOrders: [] },
// { name: "Ken Sato", deliveredOrders: [ { _id: 5, total: 2248 } ] },
// { name: "Maria Garcia", deliveredOrders: [] }
// ]
The let + $expr form
For more complex conditions, or on servers older than 5.0, use let to pass values from the current document into the sub-pipeline. Inside, you refer to them with $$ and compare with $expr:
db.customers.aggregate([
{ $lookup: {
from: "orders",
let: { custId: "$_id" }, // a variable from the customer
pipeline: [
{ $match: {
$expr: { $eq: ["$customerId", "$$custId"] }, // join condition
status: "delivered" // extra filter
} },
{ $project: { _id: 1, total: 1 } }
],
as: "deliveredOrders"
} }
])
// Same result as above
Remember the $ rule from the fundamentals lesson: inside the sub-pipeline, "$customerId" is a field of the order, and "$$custId" is the variable we passed in from the customer.
Joining on an array field
localField can also be an array. Each order has items.productId, which is a list of product ids. $lookup matches any of them:
db.orders.aggregate([
{ $lookup: {
from: "products",
localField: "items.productId",
foreignField: "_id",
pipeline: [ { $project: { _id: 0, name: 1, stock: 1 } } ],
as: "products"
} },
{ $match: { "products.stock": 0 } }, // orders containing an out-of-stock product
{ $project: { _id: 1, status: 1, products: 1 } }
])
// → [
// { _id: 4, status: "pending",
// products: [ { name: "USB Cable", stock: 0 }, { name: "Gel Pen", stock: 500 } ] }
// ]
Order 4 is pending and contains the USB Cable, which has no stock. Time to call the supplier!
Note: The matched documents may not come back in the same order as the ids in your array. If order matters, sort them yourself or match them up with the item list afterwards.
Keeping joins fast
For every document on the belt, $lookup searches the from collection. Without an index on foreignField, that’s a full collection scan for every document. With 10,000 customers, that’s 10,000 scans.
- Joining on
_id? It’s always indexed, so you’re fine. - Joining customers to orders on
customerId? Create an index:
db.orders.createIndex({ customerId: 1 })
More tips:
$matchbefore$lookup. Join only the documents you need, not the whole collection.- Trim the joined documents with a sub-pipeline
$project. Every copied field costs memory and network. - Mind the 16 MB limit. If a customer could have 100,000 orders, the
ordersarray could grow past the document size limit. Count or summarise inside the sub-pipeline instead of copying everything. - Same database only.
frommust be a collection in the same database. Since MongoDB 5.1, thefromcollection can be sharded.
Tip: If you find yourself running the same
$lookupon every single request, it may be a sign the data should be embedded instead. Revisit Embedding vs Referencing.
Note:
$unionWith(MongoDB 4.4+) is sometimes confused with$lookup. It doesn’t attach documents; it stacks the documents of another collection onto the belt, like SQL’sUNION ALL.
Common mistakes
- Mismatched types. If
customerIdis stored as the string"66a0..."but_idis anObjectId, nothing matches and every array is empty. Store references with the same type (or convert with$toObjectId, covered in Conversion Operators). - Forgetting the result is an array.
"$customer.name"on an array gives you an array of names. Use$firstor$unwindfirst. $unwindhiding unmatched documents. UsepreserveNullAndEmptyArrays: true, or use$firstinstead.- No index on
foreignField. Joins that are fast on 6 orders become painfully slow on 6 million. - Mixing up
$and$$in sub-pipelines.$fieldis the joined collection’s field;$$varis a variable fromlet.
Practice
- For every order, show its
_id,totaland the customer’semail. - For every customer, show their
nameand total amount spent (0for Maria). - Show each order with a list of the categories of the products it contains.
Show a possible answer
// 1. Order + customer email
db.orders.aggregate([
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
{ $project: { total: 1, email: { $first: "$customer.email" } } }
])
// 2. Total spent per customer
db.customers.aggregate([
{ $lookup: { from: "orders", localField: "_id", foreignField: "customerId", as: "orders" } },
{ $project: { _id: 0, name: 1, spent: { $sum: "$orders.total" } } }
])
// → Asha 2157, Rahul 498, Priya 574, Ken 2248, Maria 0
// 3. Categories in each order
db.orders.aggregate([
{ $lookup: {
from: "products",
localField: "items.productId",
foreignField: "_id",
pipeline: [ { $project: { _id: 0, category: 1 } } ],
as: "products"
} },
{ $project: { categories: { $setUnion: ["$products.category"] } } }
])
// e.g. order 5 → categories: ["electronics", "kitchen"]
Key takeaways
$lookupjoins documents from another collection in the same database. Matching documents are stapled on as an array.- It behaves like a left outer join: documents with no match stay, with an empty array.
- Use
$firstor$unwindto turn a one-element array into a single document. - A sub-pipeline (with
localField/foreignFieldsince 5.0, orlet+$expr) filters and trims joined documents. - Index the
foreignField,$matchbefore you join, and keep joined arrays small.
Next lesson: unwind and Array Aggregation
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready