Mongoose
populate() and lean()
Follow a reference, then skip hydration when you only need plain objects.
ref: "User" does not join until you `populate('userId')`. Under the hood that is extra queries (or a $lookup-shaped batch) — not free. `lean()` skips turning each result into a Mongoose document: no .save(), getters, or virtuals. List endpoints almost always want lean().
const order = await Order.findById(orderId)
.populate("userId", "name city")
.lean();
// order.userId is { _id, name, city } — or null if missingconst orders = await Order.find({ status: "pending" })
.select("amount status userId")
.lean()
.limit(50);Populate on a list of 50 orders is 50 users unless Mongoose batches — still more work than embedding name on the order snapshot (Data Modeling). Nested populate (items.productId) is how an API becomes a join tree. Missing ref → null, not throw. You cannot order.save() after lean(). Driver $lookup is explicit; populate is convenient and easy to leave on a hot path.
Interview question
What do populate() and lean() do?
populate replaces a stored ObjectId with the referenced document — extra reads, like a join. lean() returns plain objects instead of Mongoose documents, which is faster and means I cannot save() them. I lean list endpoints and I do not populate by default on every query.
Practice
The aggregation form of the same join populate would issue.