Why MongoDB Needs $lookup
MongoDB is a document database and typically favors embedding related data within a single document to avoid joins, but embedding isn't always practical: data that grows unbounded, is shared across many parent documents, or is updated independently is often better modeled as a separate collection with a reference. $lookup fills the gap this creates by performing a left outer join, matching documents from a 'foreign' collection into the current pipeline based on a field comparison, and it returns an array of matches on the joined field even when zero, one, or many documents match.
Cricket analogy: A player profile document referencing a separate 'matches' collection instead of embedding thousands of career innings mirrors why unbounded, growing data is modeled as a separate collection needing $lookup to join back in.
Simple Equality $lookup
The basic form of $lookup takes four fields: 'from' names the foreign collection, 'localField' names the field in the current pipeline's documents, 'foreignField' names the matching field in the foreign collection, and 'as' names the new array field where matched documents are placed. For every input document, MongoDB scans the foreign collection for documents where foreignField equals the input's localField value and attaches all matches as an array; if no documents match, the array is simply empty rather than causing the document to be dropped, which is the defining left-outer-join behavior.
Cricket analogy: Joining a batter's document to a separate innings collection where innings.playerId equals the batter's _id retrieves every innings they've played, attaching an array even if some batters have zero recorded innings.
db.orders.aggregate([
{ $lookup: {
from: "customers",
localField: "customerId",
foreignField: "_id",
as: "customerInfo"
}},
{ $unwind: "$customerInfo" },
{ $project: { orderId: 1, amount: 1, "customerInfo.name": 1, "customerInfo.email": 1 } }
]);$lookup followed by $unwind is a very common pairing when you expect exactly one match per input document, effectively simulating an inner join by turning the single-element array into a plain embedded object.
Correlated Sub-Pipeline $lookup
For joins that need more than simple equality — filtering the foreign collection, joining on multiple fields, or running the foreign side through its own aggregation stages — $lookup supports a 'let' clause to define variables from the input document and a nested 'pipeline' that can reference those variables via $expr and $$variableName. This form is strictly more powerful than the simple form and effectively lets you run a correlated subquery per input document, for example joining only the foreign documents that also satisfy a date range or status condition, something the simple localField/foreignField form cannot express.
Cricket analogy: Joining a bowler to only their wickets taken against a specific opponent within a date range requires a filtered sub-pipeline $lookup, since simple equality alone can't express multiple conditions.
db.customers.aggregate([
{ $lookup: {
from: "orders",
let: { custId: "$_id" },
pipeline: [
{ $match: { $expr: {
$and: [
{ $eq: ["$customerId", "$$custId"] },
{ $gte: ["$orderDate", ISODate("2026-01-01")] }
]
}}}
],
as: "recentOrders"
}}
]);$lookup performs one lookup operation per input document by default, so an unfiltered $lookup placed early in a pipeline against a large foreign collection can be very slow. Always place $match before $lookup to shrink the input side, and ensure foreignField is indexed on the joined collection.
- $lookup performs a left outer join, attaching matched foreign documents as an array even when there are zero matches.
- The simple form uses from, localField, foreignField, and as for straightforward equality joins.
- $lookup followed by $unwind approximates an inner join when exactly one match is expected per document.
- The let/pipeline form supports correlated sub-pipelines, enabling filters, multi-field joins, and further aggregation on the foreign side.
- $expr with $$variableName is required inside a sub-pipeline $lookup to reference variables defined in let.
- Indexing foreignField on the joined collection is essential for $lookup performance.
- Filtering with $match before $lookup reduces how many join operations the server must perform.
Practice what you learned
1. What type of join does $lookup perform by default?
2. What happens to an input document when no documents in the foreign collection match its localField value?
3. When is the let/pipeline form of $lookup required instead of the simple localField/foreignField form?
4. What must be used inside a sub-pipeline $lookup to reference a variable defined in let?
5. Why is placing $match before $lookup recommended for performance?
Was this page helpful?
You May Also Like
Common Aggregation Stages
A practical tour of the most frequently used aggregation stages — $match, $project, $sort, $limit, $skip, and $unwind — and how they reshape documents.
The Aggregation Pipeline
Learn how MongoDB's aggregation pipeline transforms documents through an ordered sequence of stages to filter, reshape, and summarize data.
Aggregation Performance
Learn practical techniques for diagnosing and improving the performance of MongoDB aggregation pipelines, from index usage to explain plans.