DEV Community

Tamiz Uddin
Tamiz Uddin

Posted on Originally published at tamiz.pro

Optimizing Large-Scale MongoDB Aggregation Pipelines: A Deep-Dive into Performance at Scale

Originally published on tamiz.pro.

MongoDB’s aggregation framework is one of its most powerful features, enabling complex data transformations, joins, grouping, and analytics directly within the database engine. But as datasets grow into millions or billions of documents, poorly structured pipelines can consume excessive memory, trigger disk spills, hold collection-level locks, and bring a cluster to its knees.

This deep-dive examines how the aggregation engine works under the hood, identifies the most common performance bottlenecks at scale, and walks through concrete optimization strategies—with real, runnable pipeline examples—that you can apply immediately to production systems.

How MongoDB Aggregation Works Under the Hood

The Pipeline Execution Model

Every aggregation pipeline is a sequence of stages. Each stage receives a stream of documents, transforms them, and passes the result to the next stage. The engine processes documents in batches rather than one at a time, which means:

  • Early stages with low cardinality reduction have an outsized impact on downstream performance.
  • The order of stages matters because each stage operates on the output of the previous one.
  • Stages like $sort and $group require buffering and can block until all input is consumed.
// A typical pipeline:
[{$match: {...}}, {$lookup: {...}}, {$unwind: "$items"}, {$group: {_id: "$category", total: {$sum: "$items.price"}}}, {$sort: {total: -1}}]
Enter fullscreen mode Exit fullscreen mode

The engine will attempt to push certain operations down to the storage layer (e.g., index-backed $match), but stages that require cross-document computation (like $lookup, $group, $sort without an index) run in memory and can become bottlenecks.

Memory Management and the 100MB Limit

By default, every aggregation stage is limited to 100MB of RAM. If a stage exceeds this, the entire operation fails unless allowDiskUse: true is set. While allowDiskUse can prevent failures, disk spills are orders of magnitude slower than in-memory processing because they involve serialization, file I/O, and temporary file management.

db.orders.aggregate(pipeline, {allowDiskUse: true});
Enter fullscreen mode Exit fullscreen mode

The key insight: allowDiskUse is a safety net, not an optimization strategy. A pipeline that needs to spill to disk is almost always an opportunity for restructuring.

Stage Characteristics: Streaming vs. Blocking

Understanding whether a stage is streaming (processes documents one at a time and passes them along) or blocking (must consume all input before producing output) is critical:

Stage Type Notes
$match Streaming (index-backed) Can leverage indexes; reduces cardinality early
$project Streaming Lightweight field reshaping
$sort Blocking (unless indexed) Must buffer all documents; memory-intensive
$group Blocking Must accumulate all groups; memory-intensive
$lookup Blocking Can be expensive; pipeline form is often better
$unwind Streaming Expands arrays; can increase cardinality
$limit / $skip Streaming $limit after $sort allows top-K optimization

The Query Planner's Role

MongoDB's query planner can merge certain pipeline stages with the initial FIND operation. For example, a $match stage at the beginning of the pipeline that uses indexed fields will be executed as part of the initial collection scan, reducing the number of documents loaded into the aggregation engine.

// This $match can be pushed to the storage layer:
db.orders.aggregate([{$match: {status: "completed"}}, ...])
Enter fullscreen mode Exit fullscreen mode

However, if $match appears after a $lookup or $group, it cannot be pushed down and operates on the in-memory result set.

Common Performance Bottlenecks at Scale

1. Unindexed $lookup Operations

The single most common performance killer in large-scale aggregations is unindexed $lookup. When the foreign collection doesn't have an index on the join field, MongoDB falls back to a full collection scan for every document in the source stream.

// BAD: No index on orders.customerId
[
  {$lookup: {
    from: "orders",
    localField: "_id",
    foreignField: "customerId",
    as: "orders"
  }},
  {$unwind: "$orders"},
  {$group: {_id: "$customerId", totalSpent: {$sum: "$orders.total"}}}
]
Enter fullscreen mode Exit fullscreen mode

If the customers collection has 1 million documents and the orders collection has 10 million, this pipeline performs 1 million collection scans.

2. Cartesian Product Explosions

$unwind and $lookup can multiply document counts exponentially. A customer with 10 orders, each with 5 line items, produces 50 documents after unwinding. This compounds through subsequent stages.

3. $group Without Indexes

A $group stage that groups by a non-indexed field must process every document in memory. If the pipeline feeds into $group with millions of documents, it will exceed the 100MB limit or require disk spilling.

4. Multi-Field $sort Without Index Support

Sorting by fields that don't have a compound index forces MongoDB to load all documents into memory and perform an in-memory sort, which is O(n log n) and memory-bound.

5. $facet Overuse

$facet runs multiple subqueries in parallel, but each branch is a separate aggregation pipeline. If the branches are expensive, $facet multiplies the computational cost rather than reducing it.

Optimization Strategies

Strategy 1: Pipeline Stage Ordering — $match First, $project Second

The golden rule: reduce the working set as early as possible. Always place $match stages at the beginning of the pipeline, and use $project or $addFields to drop unnecessary fields before expensive operations.

// BAD: Processing all fields through all stages
db.orders.aggregate([
  {$lookup: {from: "customers", localField: "customerId", foreignField: "_id", as: "customer"}},
  {$unwind: "$customer"},
  {$match: {status: "completed", "customer.tier": "premium"}},
  {$group: {_id: "$customer._id", total: {$sum: "$amount"}}}
])

// GOOD: Filter early, project early
db.orders.aggregate([
  {$match: {status: "completed"}},
  {$lookup: {
    from: "customers",
    localField: "customerId",
    foreignField: "_id",
    pipeline: [{$match: {tier: "premium"}}, {$project: {_id: 1, tier: 1}}],
    as: "customer"
  }},
  {$unwind: "$customer"},
  {$project: {customerId: 1, amount: 1}},
  {$group: {_id: "$customerId", total: {$sum: "$amount"}}}
])
Enter fullscreen mode Exit fullscreen mode

The optimized version filters orders first (reducing the set before the lookup), uses a pipeline-form $lookup that filters customers at the foreign collection level, and projects only the fields needed for grouping.

Strategy 2: Indexing for Aggregation

Every field used in $match, $sort, $group, and $lookup (foreignField) should have an appropriate index. For compound operations, create compound indexes that match the pipeline's access pattern.

// Create indexes that align with your pipeline
db.orders.createIndex({status: 1, customerId: 1, amount: 1});
db.customers.createIndex({tier: 1, _id: 1});
Enter fullscreen mode Exit fullscreen mode

Strategy 3: $lookup with Pipeline Syntax

The pipeline form of $lookup (available since MongoDB 3.6) is almost always superior to the classic form because it lets you push $match and $project into the foreign collection, reducing the amount of data transferred across the join.

// Classic form: pulls ALL matching documents, then filters in memory
{$lookup: {
  from: "orders",
  localField: "_id",
  foreignField: "customerId",
  as: "orders"
}}

// Pipeline form: filters at the source
{$lookup: {
  from: "orders",
  let: {custId: "$_id"},
  pipeline: [
    {$match: {$expr: {$eq: ["$customerId", "$$custId"]}}},
    {$match: {status: "completed"}},
    {$project: {amount: 1, date: 1}}
  ],
  as: "orders"
}}
Enter fullscreen mode Exit fullscreen mode

Strategy 4: Use $limit Early with $sort

If you need the top N results, place $limit immediately after $sort. MongoDB's query planner recognizes this pattern and uses a top-K sort optimization, which uses a heap of size K instead of sorting the entire result set.

db.orders.aggregate([
  {$match: {status: "completed"}},
  {$sort: {amount: -1}},
  {$limit: 100}
])
Enter fullscreen mode Exit fullscreen mode

Without $limit, this would sort all completed orders in memory.

Strategy 5: Avoid $unwind When Possible

If you need to aggregate over array elements, consider $unwind followed by $group. But if the arrays are small and you need the original structure, use $reduce or sub-pipeline $lookup to compute aggregates without expanding documents.

// Instead of unwinding, use $reduce to sum array values
db.orders.aggregate([
  {$project: {
    customerId: 1,
    totalItems: {$sum: "$items.quantity"},
    totalValue: {$sum: {$map: {
      input: "$items",
      as: "item",
      in: {$multiply: ["$$item.quantity", "$$item.price"]}
    }}}
  }}
])
Enter fullscreen mode Exit fullscreen mode

Strategy 6: Sharding-Aware Aggregations

On a sharded cluster, each shard executes the pipeline independently, and results are merged on a single mongos. To minimize merge overhead:

  • Use $match with the shard key to target specific shards.
  • Avoid $group on non-shard-key fields when possible; if necessary, use $group with _id: null only for global aggregates.
  • Place $sort and $limit after $group to reduce the merge payload.
// Shard-aware pipeline targeting specific shards
db.orders.aggregate([
  {$match: {region: "us-east"}},  // if region is part of shard key
  {$group: {_id: "$customerId", total: {$sum: "$amount"}}},
  {$sort: {total: -1}},
  {$limit: 1000}
])
Enter fullscreen mode Exit fullscreen mode

Strategy 7: Pre-Aggregation and Materialized Views

For pipelines that run frequently with the same logic, consider pre-computing results. MongoDB 4.2+ supports \$merge to write aggregation results back to a collection, effectively creating a materialized view.

// Pre-compute daily sales totals
db.dailySales.aggregate([
  {$match: {date: {$gte: new Date("2024-01-01")}}},
  {$group: {
    _id: {date: {$dateToString: {format: "%Y-%m-%d", date: "$date"}}, region: "$region"},
    total: {$sum: "$amount"},
    count: {$sum: 1}
  }},
  {$merge: {
    into: "daily_sales_summary",
    on: ["_id.date", "_id.region"],
    whenMatched: "replace",
    whenNotMatched: "insert"
  }}
], {allowDiskUse: true})
Enter fullscreen mode Exit fullscreen mode

This transforms an expensive real-time aggregation into a simple indexed query.

Real-World Case Study: E-Commerce Revenue Report

Consider a pipeline that computes monthly revenue by product category across all regions. The naive version:

// Naive version
db.orders.aggregate([
  {$lookup: {from: "order_items", localField: "_id", foreignField: "orderId", as: "items"}},
  {$unwind: "$items"},
  {$lookup: {from: "products", localField: "items.productId", foreignField: "_id", as: "product"}},
  {$unwind: "$product"},
  {$group: {
    _id: {
      month: {$month: "$date"},
      category: "$product.category",
      region: "$region"
    },
    revenue: {$sum: {$multiply: ["$items.quantity", "$items.price"]}},
    orders: {$addToSet: "$orderId"}
  }},
  {$sort: {revenue: -1}}
])
Enter fullscreen mode Exit fullscreen mode

This pipeline has several issues: two unindexed $lookup operations, $unwind doubling the document count, $addToSet accumulating all order IDs, and no early filtering.

The optimized version:

// Optimized version
db.orders.aggregate([
  // Filter early: only completed orders in the date range
  {$match: {status: "completed", date: {$gte: ISODate("2024-01-01"), $lt: ISODate("2024-02-01")}}},

  // Project only needed fields before the join
  {$project: {date: 1, region: 1}},

  // Use pipeline $lookup to join order_items with filtering and projection
  {$lookup: {
    from: "order_items",
    let: {orderId: "$_id"},
    pipeline: [
      {$match: {$expr: {$eq: ["$orderId", "$$orderId"]}}},
      {$project: {productId: 1, quantity: 1, price: 1}}
    ],
    as: "items"
  }},

  // We still unwind, but the array is now minimal
  {$unwind: "$items"},

  // Use pipeline $lookup for products, filtering by category
  {$lookup: {
    from: "products",
    let: {prodId: "$items.productId"},
    pipeline: [
      {$match: {$expr: {$eq: ["$_id", "$$prodId"]}}},
      {$project: {category: 1}}
    ],
    as: "items.product"
  }},

  // Group with pre-computed date truncation
  {$group: {
    _id: {
      month: {$dateToString: {format: "%Y-%m", date: "$date"}},
      category: "$$items.product.category",
      region: "$region"
    },
    revenue: {$sum: {$multiply: ["$items.quantity", "$items.price"]}},
    orderCount: {$sum: 1}
  }},

  {$sort: {revenue: -1}},
  {$limit: 100}
], {allowDiskUse: true})
Enter fullscreen mode Exit fullscreen mode

Key optimizations applied:

  1. Early $match reduces the working set to one month of completed orders.
  2. $project before $lookup drops unnecessary fields, reducing memory footprint.
  3. Pipeline $lookup with $project on the foreign side minimizes data transfer.
  4. No $addToSet — replaced with $sum: 1 for order counting.
  5. $limit applied after $sort enables top-K optimization.

Measuring the Impact

Use explain("executionStats") to compare before and after:

db.orders.explain("executionStats").aggregate([
  {$match: {status: "completed", date: {$gte: ISODate("2024-01-01")}}},
  ...
])
Enter fullscreen mode Exit fullscreen mode

Key metrics to watch:

  • totalDocsExamined: should drop dramatically with early $match.
  • totalKeysExamined: should be close to or less than totalDocsExamined with proper indexing.
  • executionSuccess: must be true.
  • stage: "PROJECTION_COVERED" or stage: "IXSCAN" for the initial $match indicates index utilization.

Monitoring and Profiling in Production

Using explain() Effectively

The explain() method provides a detailed breakdown of how MongoDB executes your pipeline. Key stages to look for:

  • COLLSCAN: indicates a full collection scan — add or fix indexes.
  • FETCH: document retrieval after an index scan — expected if projecting fields not in the index.
  • SORT: in-memory sort — consider a sort index or $limit.
  • GROUP: in-memory grouping — check if the grouping key is indexed.
// Get detailed execution stats
db.orders.explain("executionStats").aggregate(pipeline)
Enter fullscreen mode Exit fullscreen mode

The MongoDB Profiler

Enable the database profiler to capture slow aggregation queries:

db.setProfilingLevel(1, {slowms: 100})
Enter fullscreen mode Exit fullscreen mode

Then query the system.profile collection:

db.system.profile.find({ns: "mydb.orders", op: "command", "command.aggregate": {$exists: true}})
  .sort({ts: -1})
  .limit(10)
  .pretty()
Enter fullscreen mode Exit fullscreen mode

Atlas Performance Advisor

If you're on MongoDB Atlas, the Performance Advisor automatically suggests indexes based on slow query patterns. It analyzes query shapes and recommends compound indexes that align with your aggregation patterns.

Advanced Techniques

Window Functions: $setWindowFields

Introduced in MongoDB 5.0, $setWindowFields enables SQL-like window functions (running totals, rankings, moving averages) without expensive self-joins or sub-queries.

db.orders.aggregate([
  {$match: {date: {$gte: ISODate("2024-01-01")}}},
  {$sort: {date: 1}},
  {$setWindowFields: {
    partitionBy: "$region",
    sortBy: {date: 1},
    output: {
      runningTotal: {$sum: "$amount", window: {range: ["unbounded", "current"], unit: "day"}},
      rank: {$rank: {}},
      movingAvg: {$avg: "$amount", window: {range: [-7, 0], unit: "day"}}
    }
  }}
])
Enter fullscreen mode Exit fullscreen mode

This replaces what would traditionally require a self-join or $facet with multiple sub-pipelines.

Bucketing and Histogramming

For analytics on large datasets, $bucket and $bucketAuto are more efficient than $group because they pre-partition data into ranges.

db.orders.aggregate([
  {$match: {status: "completed"}},
  {$bucketAuto:
Enter fullscreen mode Exit fullscreen mode

$facet vs. Parallel Queries

When you need multiple aggregation results from the same dataset, $facet can be expensive because each branch re-scans the pipeline input. For read-heavy workloads, splitting into separate queries and aggregating results in the application layer is often faster.

// Instead of $facet with expensive branches, run separate queries
const [revenue, volume, topCustomers] = await Promise.all([
  db.orders.aggregate(revenuePipeline).toArray(),
  db.orders.aggregate(volumePipeline).toArray(),
  db.orders.aggregate(topCustomersPipeline).toArray()
])
Enter fullscreen mode Exit fullscreen mode

Handling the 100MB Limit Gracefully

When you must process large result sets, allowDiskUse: true prevents failures but introduces disk I/O. A better approach is to restructure the pipeline to process data in chunks:

// Process in batches to stay under memory limits
const batchSize = 10000;
let skip = 0;
let hasMore = true;

while (hasMore) {
  const batch = await db.orders.aggregate([
    {$match: {status: "completed"}},
    {$sort: {date: 1}},
    {$skip: skip},
    {$limit: batchSize},
    ...restOfPipeline
  ], {allowDiskUse: true}).toArray();

  // Process batch...
  skip += batchSize;
  hasMore = batch.length === batchSize;
}
Enter fullscreen mode Exit fullscreen mode

Frequently Asked Questions

Q: Does allowDiskUse: true make pipelines slower?

A: Yes, significantly. Disk spills involve serializing BSON, writing to temporary files, and reading back. Use it only as a safety net while you restructure the pipeline to stay in memory.

Q: How do I know if my $lookup is using an index?

A: Run explain("executionStats") on the pipeline. If the $lookup stage shows stage: "COLLSCAN" on the foreign collection, no index is being used. Ensure the foreignField has an index.

Q: What's the difference between $lookup with pipeline and the classic form?

A: The classic form retrieves all documents matching foreignField and filters client-side. The pipeline form pushes $match and $project into the foreign collection, allowing index usage and reducing data transfer. Always prefer the pipeline form.


Optimizing MongoDB aggregation pipelines at scale is about understanding the execution model, pushing filtering and projection as early as possible, and ensuring every join and sort has appropriate index support. The biggest wins come from restructuring the pipeline to minimize the working set size—often a 10x or 100x reduction in execution time is achievable with just a few targeted changes.

For teams managing large MongoDB deployments, investing in pipeline optimization pays dividends far beyond raw query speed: reduced memory pressure, fewer lock escalations, and more predictable performance under load. Start with explain(), identify the blocking stages, and work backward to restructure the pipeline around indexed access patterns.

Top comments (0)