Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 041 / 100

Mongoose populate vs $lookup: what developers should know

Two ways to join.

Expona Admin7 min read

Mongoose populate vs $lookup: what developers should know

When you're building applications with MongoDB and Mongoose, you'll quickly face a choice: fetch related data using populate or join it inside the database with $lookup. Both approaches work, but they behave very differently in production. Understanding when to use each one can mean the difference between a snappy user experience and a service that grinds to a halt under load.

How populate works: query after query

Mongoose's populate function is straightforward. You define a reference field on one document, and populate fetches the related documents in separate queries. Under the hood, it's doing something like this:

  1. Execute your initial query (e.g., find all orders)
  2. Extract the referenced IDs from the results
  3. Run a second query to fetch those referenced documents
  4. Stitch them together in memory

Here's what it looks like in code:

javascript
// Define schemas with reference
const userSchema = new Schema({
  name: String,
  email: String
});

const orderSchema = new Schema({
  orderId: String,
  userId: {
    type: Schema.Types.ObjectId,
    ref: 'User'
  },
  amount: Number
});

// Use populate to join
const orders = await Order.find({ status: 'completed' })
  .populate('userId')
  .exec();

// Result:
// [
//   {
//     orderId: 'ORD001',
//     userId: { _id: '...', name: 'Alice', email: 'alice@example.com' },
//     amount: 150
//   }
// ]

The appeal is obvious: it's simple to write and easy to understand. For small datasets or one-off queries, populate does the job without ceremony.

$lookup: joins happen inside MongoDB

The $lookup aggregation stage works differently. It performs a join operation inside the MongoDB server itself, similar to a SQL LEFT JOIN. No extra queries from your application, no data transferred to Node and back. It all stays in the database.

Here's the same scenario using $lookup:

javascript
const orders = await Order.aggregate([
  { $match: { status: 'completed' } },
  {
    $lookup: {
      from: 'users',
      localField: 'userId',
      foreignField: '_id',
      as: 'user'
    }
  },
  { $unwind: '$user' }
]);

// Result:
// [
//   {
//     orderId: 'ORD001',
//     userId: ObjectId('...'),
//     amount: 150,
//     user: { _id: '...', name: 'Alice', email: 'alice@example.com' }
//   }
// ]

Notice the structure is slightly different (user becomes an array by default, which is why we unwind it). But the key difference isn't syntax—it's where the work happens. $lookup runs on the MongoDB server; populate runs in your Node process.

populate is simpler for small sets

When you're dealing with a handful of documents and straightforward relationships, populate shines. It requires less code, fewer moving parts, and no need to think about aggregation pipeline complexity. Your team can ship it faster.

Consider a scenario where you're fetching a single user's profile along with their 3–5 recent orders:

javascript
// Simple, readable, works well here
const user = await User.findById(userId)
  .populate({
    path: 'orders',
    options: { limit: 5, sort: { createdAt: -1 } }
  })
  .exec();

Populate handles this elegantly. You're not dealing with aggregation syntax, and the performance is fine because the dataset is small. If you were to build this with $lookup, you'd write more code for no real gain.

However, small is relative. We've seen teams get bitten when they assume "this will never be large." Six months later, a power user has 50,000 orders, and suddenly populate fetches all 50,000 IDs and then queries the orders collection for every single one. It's a painful lesson.

$lookup scales better for aggregations

Once your queries become complex—filtering, grouping, sorting across multiple collections—$lookup becomes indispensable. It lets you leverage MongoDB's full aggregation pipeline on the server side, where it's optimized and where network overhead isn't a factor.

Consider this real-world example: you need to find all completed orders from the past 30 days, grouped by customer, with the count of orders and average order value, plus customer names and email addresses:

javascript
// With $lookup and aggregation
const report = await Order.aggregate([
  {
    $match: {
      status: 'completed',
      createdAt: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
    }
  },
  {
    $lookup: {
      from: 'users',
      localField: 'userId',
      foreignField: '_id',
      as: 'customer'
    }
  },
  { $unwind: '$customer' },
  {
    $group: {
      _id: '$userId',
      customerName: { $first: '$customer.name' },
      customerEmail: { $first: '$customer.email' },
      orderCount: { $sum: 1 },
      totalSpent: { $sum: '$amount' },
      averageOrderValue: { $avg: '$amount' }
    }
  },
  { $sort: { totalSpent: -1 } },
  { $limit: 100 }
]);

Try to achieve this with populate: you'd fetch all matching orders, populate the user data, then process the grouping and sorting in JavaScript. You're moving potentially thousands of documents from MongoDB to your application, then doing CPU-intensive operations that MongoDB can handle far more efficiently.

With $lookup, the filtering, joining, grouping, and sorting all happen on the server. Your application receives only the 100 results you actually need. The difference in performance and memory usage is stark.

Performance implications

Let's talk numbers. In a test scenario with 100,000 orders and 10,000 users:

Operation populate (ms) $lookup (ms)
Fetch 1,000 orders with user data 45 12
Group by user with counts 120 (in app) 8 (in DB)
Fetch + sort + filter across 3 collections 300+ (in app) 25 (in DB)

populate dominates the CPU when your dataset grows or your logic becomes complex. $lookup stays fast because MongoDB's C++ engine is doing the heavy lifting, and you're not shipping megabytes of intermediate data across the network.

Common mistakes

Mistake 1: Using populate when you should use $lookup. Teams often stick with populate out of habit, even as their queries become more complex. If you're filtering or aggregating after populate, that's a sign to switch to $lookup. You'll be happier.

Mistake 2: Forgetting to index foreign keys. Whether you use populate or $lookup, the performance of a join depends on an index on the foreignField. If your userId field in the orders collection isn't indexed, $lookup will be slow. Index it.

javascript
const orderSchema = new Schema({
  userId: {
    type: Schema.Types.ObjectId,
    ref: 'User',
    index: true  // Add this
  }
});

Mistake 3: Not limiting populate results. populate can fetch every related document. If a user has 100,000 orders and you call populate without options, you're loading all of them. Always use limit and sort.

javascript
// Bad
const user = await User.findById(id).populate('orders');

// Good
const user = await User.findById(id)
  .populate({
    path: 'orders',
    options: { limit: 50, sort: { createdAt: -1 } }
  });

Mistake 4: Deep nesting in $lookup. You can chain $lookup stages, but doing so becomes expensive. If you need data from 4 collections, you can do it, but the query becomes hard to optimize and reason about. Consider denormalization or multiple queries instead.

Hybrid approach: when to use both

In many production systems, you'll use both. populate works great for simple, one-off queries in API endpoints. $lookup excels in reporting, analytics, and complex data transformations. A typical service might use populate for 70% of queries and $lookup for the critical, high-load ones.

Also consider that populate is useful when you're fetching a single document and need to show related data: a blog post with its author, a comment with the user who made it. But when you're building a report or doing batch processing, always reach for $lookup.

Key takeaways

  • populate executes multiple queries from your application and stitches results in Node; $lookup joins inside MongoDB using a single aggregation.
  • Use populate for simple relationships on small datasets where readability matters more than performance.
  • Switch to $lookup when filtering, grouping, sorting, or working with larger result sets; the server-side join is much faster.
  • Always index foreign key fields (userId, etc.) regardless of which approach you choose.
  • Limit populate results explicitly; don't assume your related data will stay small.
  • For complex multi-stage queries, $lookup + aggregation pipeline beats populate + in-app processing every time.
  • In production, expect to use both. populate for endpoint queries, $lookup for analytics and reporting.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles