Chapter 75 β populate() vs $lookup
π Definitions
populate()β a Mongoose convenience that runs a second query to load referenced documents and attach them.$lookupβ a MongoDB aggregation stage that joins two collections inside a single query.
π At a Glance
populate() |
$lookup |
|
|---|---|---|
| Layer | Mongoose (app-side) | MongoDB (DB-side) |
| Queries | 2+ (round-trip per population) | 1 |
| Syntax | Concise | Verbose |
| Filter / sort joined fields | Limited | Full power |
| Multi-level joins | Easy with chained populate | Nested pipelines |
| Read-only? | Yes | Yes (unless combined with $out/$merge) |
π» Code Example β Setup
const userSchema = new mongoose.Schema({
name: String,
email: String,
});
const orderSchema = new mongoose.Schema({
user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
items: [{
product: { type: mongoose.Schema.Types.ObjectId, ref: "Product" },
quantity: Number,
price: Number,
}],
status: String,
});
export const User = mongoose.model("User", userSchema);
export const Order = mongoose.model("Order", orderSchema);
export const Product = mongoose.model("Product", productSchema);π» Code Example β populate()
const order = await Order.findById(id)
.populate("user", "name email") // pick fields
.populate("items.product", "name price");
// order.user.name, order.items[0].product.name etc.Behind the scenes
1. Order.findById(id) β 1 query
2. User.find({ _id: order.user }) β 2nd query
3. Product.find({ _id: { $in: ids } }) β 3rd query
4. Mongoose stitches them together in JSπ» Code Example β populate with Filtering
// Only populate active products
const order = await Order.findById(id).populate({
path: "items.product",
match: { active: true }, // products that don't match β null
select: "name price",
});π» Code Example β populate with Conditions on the Parent
// Can't easily say "give me orders where the user is in country IN"
// β that's where $lookup winsπ» Code Example β $lookup
const orders = await Order.aggregate([
{ $match: { status: "paid" } },
{
$lookup: {
from: "users",
localField: "user",
foreignField: "_id",
as: "user",
},
},
{ $unwind: "$user" }, // flatten the user array
{ $match: { "user.country": "IN" } }, // filter by joined field
{ $project: { _id: 0, total: 1, "user.name": 1, "user.email": 1 } },
]);This is impossible with populate alone β you can't filter on the populated field at the DB level.
π» Code Example β $lookup with Sub-Pipeline (MongoDB 3.6+)
db.orders.aggregate([
{
$lookup: {
from: "products",
let: { itemIds: "$items.product" },
pipeline: [
{ $match: { $expr: { $in: ["$_id", "$$itemIds"] }, active: true } },
{ $project: { name: 1, price: 1 } },
],
as: "products",
},
},
]);This is the modern, flexible form of $lookup.
π» Code Example β Multi-Level Populate
// User β Orders β Items β Product
const user = await User.findById(id)
.populate({
path: "orders",
populate: { path: "items.product", select: "name price" },
});π» Code Example β Multi-Level $lookup
db.users.aggregate([
{
$lookup: {
from: "orders",
let: { uid: "$_id" },
pipeline: [
{ $match: { $expr: { $eq: ["$user", "$$uid"] } } },
{
$lookup: {
from: "products",
localField: "items.product",
foreignField: "_id",
as: "products",
},
},
],
as: "orders",
},
},
]);βοΈ When to Use Which
Prefer populate whenβ¦
- Simple parent-child fetching.
- You don't need to filter/sort by joined fields.
- The number of distinct foreign IDs is small (one extra round-trip is cheap).
- You like the cleaner syntax for everyday CRUD.
Prefer $lookup whenβ¦
- You need to filter or sort by the joined collection.
- You need to do complex aggregations (group, count, etc.).
- You want one DB round-trip instead of two.
- The data is large and reducing round-trips matters.
β οΈ Performance Notes
$lookupis not free β index theforeignField(usually_idis already indexed, but custom ones aren't).populatewithmatchfilters in JS β so it fetches all matches first, then filters. Be careful with huge sets.- Multi-level
populatecan create N+1 patterns; consider$lookupif it hits performance.
π» Code Example β Denormalization (Sometimes the Best Choice)
Instead of joining each time, store frequently-needed fields together:
// orderSchema
{
user: { type: ObjectId, ref: "User" },
userName: String, // β denormalized
userEmail: String,
}Trade off: faster reads, harder updates (you must sync changes). Common in dashboards and reporting.
π― Likely Interview Questions
populate()vs$lookup? βpopulateis Mongoose-only and runs a second query ($inon ids) then stitches results in Node;$lookupis a server-side join in one aggregation round trip and can filter/sort on joined fields.- When would you choose
$lookup? β When you filter, sort, group or paginate by fields of the joined collection, need computed results, or want one round trip for reporting/analytics. - How do you filter on a populated field? β Either
populate({ match: ... })(post-filter) or$lookup+$match. - What's denormalization, and when is it worth it? β Copying fields from one document into another (e.g. customer name into orders) to avoid joins; worth it when those fields are read often and change rarely, or when a historical snapshot is desired.
- Does
populatecause N+1 queries? β No β it batches ids into one$inquery per populated path; but nested/deep populates add one query per level. - How do you populate across multiple levels? β
populate({ path: "author", populate: { path: "company" } }), or nested$lookupstages. - Why does
$lookupneed an index? β It searches the joined collection'sforeignFieldfor every input document; without an index each lookup is a collection scan. - What is virtual populate? β Defining a virtual on the parent (
ref,localField,foreignField) so you can populate children that store the parent id, without keeping an array of child ids in the parent.
β Aggregation Pipeline | Index | Next: Transactions & Consistency β