MongoDB has joins. $lookup has been in the aggregation framework since 3.2, supports correlated pipelines, and since 5.1 works against sharded collections. So “MongoDB can’t do joins” is out of date. What’s still true is that $lookup has a cost model you need to understand, and a schema that relies on joins for every request is usually fighting the database.
Examples use mongosh against MongoDB 7 or 8. They weren’t run against a live server while writing; outputs are illustrative.
What $lookup does
Two collections:
// orders
{ _id: 1, customerId: 7, total: 120, createdAt: ISODate("2026-09-20") }
{ _id: 2, customerId: 9, total: 45, createdAt: ISODate("2026-09-21") }
// customers
{ _id: 7, name: "Ana", tier: "gold" }
{ _id: 9, name: "Ben", tier: "basic" }The equality form:
db.orders.aggregate([
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } }
])
// { _id: 1, customerId: 7, total: 120, ..., customer: [ { _id: 7, name: "Ana", tier: "gold" } ] }
// { _id: 2, customerId: 9, total: 45, ..., customer: [ { _id: 9, name: "Ben", tier: "basic" } ] }Two things to notice. First, customer is always an array, even for a one-to-one relationship, because MongoDB doesn’t know there’s only one match. If no customer matches, you get customer: [], not a missing field and not null: $lookup behaves like a left outer join. Code that does order.customer.name breaks; you need order.customer[0]?.name, or an $unwind or $first afterwards.
Second, and this is the cost model: conceptually, for each order coming in, MongoDB runs a query like db.customers.find({ _id: order.customerId }). There are optimizations (caching for repeated values, for instance), but the right mental model is “one inner query per input document”. A pipeline that feeds 2 million orders into $lookup runs something like 2 million inner lookups.
The index on the foreign field
That inner query is only cheap if it can use an index. Here foreignField is _id, which always has an index, so each lookup is a quick point read. Join on a field without an index and every inner query is a collection scan of the foreign collection.
Do the arithmetic for an interviewer: 10,000 input orders joined to a 500,000-document shipments collection on an unindexed orderId field means 10,000 scans of 500,000 documents, which is 5 billion document examinations. With db.shipments.createIndex({ orderId: 1 }), it’s 10,000 index lookups. That’s the single most impactful fix for slow $lookup pipelines, and it’s the first thing to say.
You can confirm with explain("executionStats") on the aggregation. Recent versions report per-stage stats for $lookup, including totalDocsExamined and indexesUsed; if indexesUsed is empty and docs examined is enormous, you’ve found it.
Reduce the input before joining
The second fix is to send fewer documents into the join. Compare:
// joins every order, then keeps last week's gold-tier ones
[
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
{ $match: { createdAt: { $gte: lastWeek }, "customer.tier": "gold" } }
]// filters orders first, then joins only those
[
{ $match: { createdAt: { $gte: lastWeek } } },
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "customer" } },
{ $match: { "customer.tier": "gold" } }
]The optimizer does split and move the part of a $match that only references local fields ahead of a $lookup, so the first version may get rewritten. But writing it the second way is clearer and doesn’t depend on the optimizer spotting it. The tier filter has to stay after the join, because the tier only exists once the customer is attached.
If you only need a top-N list, put $sort and $limit before the lookup too. Joining 20 orders is cheap; joining 2 million and then keeping 20 is not.
The pipeline form and correlated subqueries
When you need conditions beyond simple equality, or want to limit what comes back, use the pipeline form:
db.customers.aggregate([
{ $match: { tier: "gold" } },
{ $lookup: {
from: "orders",
localField: "_id",
foreignField: "customerId",
pipeline: [
{ $match: { status: "shipped" } },
{ $sort: { createdAt: -1 } },
{ $limit: 3 },
{ $project: { _id: 1, total: 1, createdAt: 1 } }
],
as: "recentOrders"
} }
])Combining localField/foreignField with pipeline (allowed since 5.0) keeps the equality join able to use an index on orders.customerId, while the sub-pipeline limits each customer to three projected orders. The older style with let and $expr: { $eq: ["$customerId", "$$custId"] } works too, but $expr equality has historically been less index-friendly, so prefer the combined form when the join is an equality.
The $limit inside matters for a size reason. The joined array becomes part of one output document, and BSON documents are limited to 16 MB. A customer with 300,000 orders joined without a limit can blow past that and fail with a BSONObjectTooLarge-style error. The exception: when $unwind on the as field directly follows $lookup, the optimizer coalesces them, and the big array is never materialised.
When a join is the wrong answer
If the product page runs a $lookup against categories and brands on every request, ask why those names aren’t stored on the product. Category names change rarely; copying { categoryId, categoryName } onto products turns a join into a field read, at the cost of an updateMany when a category is renamed. That trade is almost always worth it for hot read paths.
$lookup fits better for reports, admin screens, background jobs and relationships where the joined data changes too often to duplicate. A useful line in interviews: “I design documents so the main read paths don’t need a join, and I use $lookup for the less frequent ones.”
Sharding adds cost. $lookup against a sharded foreign collection works since 5.1, but each inner query may need to reach several shards unless it can target by shard key, so the per-document cost grows.
Interview practice
An admin dashboard shows the 50 most recent support tickets with the customer’s name and plan. The pipeline below takes 6 seconds. tickets has 3 million documents, users has 800,000, and the only index on users is _id.
db.tickets.aggregate([
{ $lookup: { from: "users", localField: "userEmail", foreignField: "email", as: "user" } },
{ $sort: { createdAt: -1 } },
{ $limit: 50 }
])What would you change?
Answer
Three things are wrong. The join runs before the sort and limit, so all 3 million tickets get joined and 2,999,950 of those joins are wasted. The foreign field email has no index, so each of those joins scans 800,000 users. And the join key is an email, which can change; a userId reference would be more reliable.
First, reorder so the join only sees 50 documents:
db.tickets.aggregate([
{ $sort: { createdAt: -1 } },
{ $limit: 50 },
{ $lookup: { from: "users", localField: "userEmail", foreignField: "email",
pipeline: [ { $project: { name: 1, plan: 1 } } ], as: "user" } },
{ $set: { user: { $first: "$user" } } }
])Then add db.tickets.createIndex({ createdAt: -1 }) so the sort and limit come straight from the index, and db.users.createIndex({ email: 1 }, { unique: true }) so each of the 50 lookups is a point read. The $first turns the one-element array into an embedded document for the UI. Longer term, I’d store userId on tickets and join on _id, and possibly copy the user’s name onto the ticket at creation, since support agents mostly want the name at the time the ticket was opened anyway.