Skip to content

Mongoose

populate() and lean()

Follow a reference, then skip hydration when you only need plain objects.

IntermediateAbout 6 minutes

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 missing
Resolve the user on an order. lean for a JSON API.
const orders = await Order.find({ status: "pending" })
  .select("amount status userId")
  .lean()
  .limit(50);
List path — no populate, no hydration

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?

Think about it first.

Practice

The aggregation form of the same join populate would issue.