SQL databases offer several join algorithms. The query planner selects the most efficient one by evaluating cardinality and estimated cost. For example, a Nested Loop join is ideal when the outer table has few rows and an index allows fast access to the inner table. In contrast, a Hash Join is better suited for situations where the outer table contains many rows and the inner table must be fully scanned, resulting in fewer costly loops.
While MongoDB provides similar algorithms, adapted to flexible documents, being a NoSQL database means it shifts more responsibility to the developer. Developers must design for optimal data access, already in the data model, but has the advantage of resulting in more predictable performance.
I'll base my example on a question on Reddit: Optimizing a MongoDB JOIN with $lookup and $limit. I use a collection of users, where each user references a profile. The profile has a status. The query lists the users with no profile or with a profile with a status equal to 2.
In my demo, I set up two profile keys: "_id," which is automatically indexed in MongoDB, and an "ID" field, which is not. This setup illustrates both situations—an indexed lookup table versus a non-indexed one. Normally, you'd use just one method, depending on which join algorithm you favor.
db.profiles.drop()
db.users.drop()
db.profiles.insertMany([
{ _id:102, ID: 102, status: 2 },
{ _id:201, ID: 201, status: 1 },
{ _id:302, ID: 302, status: 2 },
{ _id:403, ID: 403, status: 3 }
]);
db.users.insertMany([
{ name: "Alice" , profileID: 403 }, // profile status = 3
{ name: "Bob", profileID: 102 }, // profile status = 2
{ name: "Charlie", profileID: 201 }, // profile status = 1
{ name: "Diana", profileID: 102 }, // profile status = 2
{ name: "Eve" }, // no profile
{ name: "Franck" , profileID: 403 }, // profile status = 3
{ name: "Gaspar" , profileID: 403 }, // profile status = 3
{ name: "Hans" , profileID: 403 }, // profile status = 3
{ name: "Iona" , profileID: 403 }, // profile status = 3
{ name: "Jane" , profileID: 403 }, // profile status = 3
{ name: "Karl" , profileID: 403 }, // profile status = 3
{ name: "Lili" }, // no profile
{ name: "Math" }, // no profile
{ name: "Niall" }, // no profile
{ name: "Oscar" , profileID: 403 }, // status = 3
{ name: "Paula" , profileID: 102 }, // status = 2
{ name: "Quentin" , profileID: 201 }, // status = 1
{ name: "Ravi" , profileID: 102 }, // status = 2
{ name: "Sofia" }, // no profile
{ name: "Takumi" , profileID: 403 }, // status = 3
{ name: "Uma" , profileID: 403 }, // status = 3
{ name: "Viktor" , profileID: 403 }, // status = 3
{ name: "Wafa" , profileID: 403 }, // status = 3
{ name: "Ximena" , profileID: 403 }, // status = 3
{ name: "Yara" }, // no profile
{ name: "Zubair" }, // no profile
]);
Here is my query on this small data set:
db.users.aggregate([
{
$lookup: {
from: "profiles",
localField: "profileID",
foreignField: "ID",
as: "profile"
}
},
{
$match: {
$or: [
{ profile: { $eq: [] } }, // no profile
{ profile: { $elemMatch: { status: 2 } } } // profile status = 2
]
}
},
{
$project: {
_id: 0,
name: 1,
"profile.status": 1 // keep only the status field from joined profile
}
}
]);
Note that the first optimization I did, is replacing the join expression (let: { userId: "$_id" },pipeline: [ { $match: { $expr: { $eq: ["$userId", "$$userId"] } } } ]) with localField/foreignField to allow the push down of the join predicate (EQ_LOOKUP)
Here is the result:
[
{ name: 'Bob', profile: [ { status: 2 } ] },
{ name: 'Diana', profile: [ { status: 2 } ] },
{ name: 'Eve', profile: [] },
{ name: 'Lili', profile: [] },
{ name: 'Math', profile: [] },
{ name: 'Niall', profile: [] },
{ name: 'Paula', profile: [ { status: 2 } ] },
{ name: 'Ravi', profile: [ { status: 2 } ] },
{ name: 'Sofia', profile: [] },
{ name: 'Yara', profile: [] },
{ name: 'Zubair', profile: [] },
]
Type "it" for more
test>
To scale the number of users, I multiply each existing user by 10,000 using the following script:
const currentUsers = db.users.find({},{_id:0, name:1, profileID:1});
currentUsers.forEach(userDoc => {
print(`Inserting 10,000 documents for: ${JSON.stringify(userDoc)}`);
for (let i = 0; i < 10000; i++) {
const newUsers = [];
const clone = { ...userDoc };
clone.name=`${i}${clone.name}`
newUsers.push(clone);
db.users.insertMany(newUsers);
}
})
I have now 260,026 users.
Indexed nested loop join
I run my query with explain("executionStats") and extract the most important statistics about the time and the number of documents examined:
x=db.users.aggregate([
{ "$lookup" : {
"from" : "profiles",
"localField" : "profileID",
"foreignField" : "_id",
"as" : "profile" }},
{
$match: {
$or: [
{ "profile": { $eq: [] } },
{ "profile": { $elemMatch: { status: 2 } } },
]
}
},
]).explain("executionStats")
xs=x["stages"][0]["$cursor"]["executionStats"];
xp=x["stages"][0]["$cursor"]["queryPlanner"]["winningPlan"]["queryPlan"];
print("nReturned:", xs["nReturned"],
"executionTimeMillis:", xs["executionTimeMillis"],
"totalKeysExamined:", xs["totalKeysExamined"],
"totalDocsExamined:", xs["totalDocsExamined"],
xp["stage"]+" strategy:", xp["strategy"],
)
The lookup stage has returned all documents, as it must be joined before filtering, in 2.5 seconds:
nReturned: 260026
executionTimeMillis: 2456
totalKeysExamined: 190019
totalDocsExamined: 450045
EQ_LOOKUP strategy: IndexedLoopJoin
The equality lookup used an indexed loop join strategy, with an index scan for each document:
-
nReturned: 260026: All local documents with their joined profile arrays -
executionTimeMillis: 2456: Total execution time including both join and filter stages -
totalKeysExamined: 190019: Only keys that found matches in the profiles collection's index on "_id" are accounted (lookup_query_stats). The index can determine "key not found" without actually examining a key entry. -
totalDocsExamined: 450045: users collection scan (260,026) + profile documents fetched (190,019)
The number of profiles examined is high compared to the number of profiles in the collection, then another algorithm can be faster by reading all profiles once and lookup from a hash table.
Hash Join on small unindexed table
MongoDB 8.0 chooses a hash join on the following conditions:
-
allowDiskUse: trueis set (required for spilling if hash table exceeds memory) - Foreign collection is small enough - controlled by these parameters:
internalQueryCollectionMaxNoOfDocumentsToChooseHashJoin(default: 10,000 docs),internalQueryCollectionMaxDataSizeBytesToChooseHashJoin(default: 100 MB), andinternalQueryCollectionMaxStorageSizeBytesToChooseHashJoin(default: 100 MB) - No compatible index exists, disk use is allowed and hash join is more efficient
To show that, I use the "ID" field, that is not indexed, for the lookup:
x=db.users.aggregate([
{ "$lookup" : {
"from" : "profiles",
"localField" : "profileID",
"foreignField" : "ID",
"as" : "profile" }},
{
$match: {
$or: [
{ "profile": { $eq: [] } },
{ "profile": { $elemMatch: { status: 2 } } },
]
}
},
],{ allowDiskUse: true } ).explain("executionStats")
xs=x["stages"][0]["$cursor"]["executionStats"];
xp=x["stages"][0]["$cursor"]["queryPlanner"]["winningPlan"]["queryPlan"];
print("nReturned:", xs["nReturned"],
"executionTimeMillis:", xs["executionTimeMillis"],
"totalKeysExamined:", xs["totalKeysExamined"],
"totalDocsExamined:", xs["totalDocsExamined"],
xp["stage"]+" strategy:", xp["strategy"],
)
The HashJoin completed 3.3x faster (750ms vs 2,456ms) with significantly different execution patterns:
nReturned: 260026
executionTimeMillis: 750
totalKeysExamined: 0
totalDocsExamined: 260030
EQ_LOOKUP strategy: HashJoin
HashJoin Works in two phases, with no index required (totalKeysExamined: 0):
- Build Phase - Scans the foreign collection once to build an in-memory hash table keyed by foreignField values. It has read the 4 profiles.
- Probe Phase - Scans the local collection once, probing the hash table for each local key. It has read 260,026 users.
The total is 260,030 documents examined.
Nested loop join without index
A third option is a nested loop join that scans the collection in each loop, rather than using an index or building a hash table. I disable disk usage to avoid hash join and use the non-indexed field. to avoid indexed nested loop:
x=db.users.aggregate([
{ "$lookup" : {
"from" : "profiles",
"localField" : "profileID",
"foreignField" : "ID",
"as" : "profile" }},
{
$match: {
$or: [
{ "profile": { $eq: [] } },
{ "profile": { $elemMatch: { status: 2 } } },
]
}
},
],{ allowDiskUse: false } ).explain("executionStats")
xs=x["stages"][0]["$cursor"]["executionStats"];
xp=x["stages"][0]["$cursor"]["queryPlanner"]["winningPlan"]["queryPlan"];
print("nReturned:", xs["nReturned"],
"executionTimeMillis:", xs["executionTimeMillis"],
"totalKeysExamined:", xs["totalKeysExamined"],
"totalDocsExamined:", xs["totalDocsExamined"],
xp["stage"]+" strategy:", xp["strategy"],
)
Here are the execution statistics:
nReturned: 260026
executionTimeMillis: 1618
totalKeysExamined: 0
totalDocsExamined: 1300130
EQ_LOOKUP strategy: NestedLoopJoin
Like other algorithms, 260,026 users were read. Since there was no index on the foreign field, threre's no index scan at all (totalKeysExamined: 0). This caused the system to scan the 4 profiles for each user, resulting in a total of 260,026 + 4 × 260,026 = 1300130 documents examined.
In this example, this approach is five times more costly than IndexedLoopJoin in terms of documents examined, and three times more than HashJoin because NestedLoopJoin requires repeatedly scanning the foreign collection. Interestingly, because the lookup collection is very small and sequential scans are cache-friendly, the execution time surpasses that of the indexed nested loop in this case, as index seeks incur additional costs.
Summary
Like with any database, it is important to understand the join algorithms. When you use a lookup in the aggregtion pipleline, MongoDB selects the join strategy based on the query, collections and existing indexes. The three algorithms are:
-
IndexedLoopJoin - Best when:
- Compatible index exists on foreignField
- Low to medium match rate
- Foreign collection is large
-
HashJoin - Best when:
- allowDiskUse: true is set
- Foreign collection is small (< 10,000 docs, < 100MB)
- High match rate or no compatible index
-
NestedLoopJoin - Fallback when:
- No compatible index exists
- allowDiskUse: false prevents HashJoin
- Acceptable only for tiny foreign collections
Unlike SQL databases, where the optimizer makes all decisions but can also lead to surprises, MongoDB shifts responsibility to developers. You must:
- Design your schema with join performance in mind. For bounded relstionships, embedding may be the best solution.
- Understand your data (collection sizes, match rates) to predict which strategy will be be the best.
-
Test different strategies by creating/dropping indexes and toggling
allowDiskUse -
Measure performance using
explain("executionStats")to validate your choices.
This design favors predictability and control over relying on automatic optimization. So when you hear statements like 'joins are slow' or 'lookups are fast', take time to understand the facts, how these operations are actually executed, before forming an opinion.
Top comments (0)