The aggregation pipeline is MongoDB's most powerful feature, yet most developers only scratch the surface with basic $group operations. This guide covers the patterns I use daily to build complex data analysis features without leaving the database layer.

Complex analytics that once required separate tools can be built with aggregation pipelines
The Golden Rule: Filter First
The single most important optimization is placing $match stages as early as possible. MongoDB can use indexes on $match stages that appear at the beginning of the pipeline, but not on those that come after transformations.
// GOOD: $match first, uses index on "status" and "createdAt"
db.orders.aggregate([
{ $match: {
status: "completed",
createdAt: { $gte: ISODate("2026-01-01") }
}},
{ $group: {
_id: "$customerId",
totalSpent: { $sum: "$amount" },
orderCount: { $sum: 1 }
}},
{ $sort: { totalSpent: -1 } },
{ $limit: 20 }
]);
// BAD: $group first, scans entire collection
db.orders.aggregate([
{ $group: { _id: "$customerId", totalSpent: { $sum: "$amount" } } },
{ $match: { totalSpent: { $gte: 1000 } } }
]);Advanced Pattern: $facet for Parallel Aggregation
The $facet stage lets you run multiple aggregation pipelines in parallel on the same input documents. This is incredibly useful for dashboard APIs where you need multiple metrics from the same dataset:
db.orders.aggregate([
{ $match: { createdAt: { $gte: ISODate("2026-01-01") } } },
{ $facet: {
"totalRevenue": [
{ $group: { _id: null, total: { $sum: "$amount" } } }
],
"byCategory": [
{ $group: { _id: "$category", revenue: { $sum: "$amount" } } },
{ $sort: { revenue: -1 } }
],
"monthlyTrend": [
{ $group: {
_id: { $month: "$createdAt" },
revenue: { $sum: "$amount" },
count: { $sum: 1 }
}},
{ $sort: { "_id": 1 } }
]
}}
]);One query, three different analytical views. Without $facet, this would require three separate queries.

$facet enables multiple analytical views from a single query execution
Materialized Views with $merge
For expensive aggregations that power dashboards, running them on every request is wasteful. The $merge stage writes aggregation results to a collection, creating a materialized view:
// Run on a schedule (e.g., every hour via cron)
db.orders.aggregate([
{ $group: {
_id: { date: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } } },
revenue: { $sum: "$amount" },
orderCount: { $sum: 1 },
avgOrderValue: { $avg: "$amount" }
}},
{ $merge: {
into: "daily_revenue_summary",
on: "_id",
whenMatched: "replace",
whenNotMatched: "insert"
}}
]);Your dashboard API then reads from daily_revenue_summary — a simple, fast, indexed collection — instead of running the full aggregation pipeline on every request.
Performance Checklist
- $match first — Always filter before transforming to leverage indexes
- $project early — Drop unneeded fields to reduce memory usage in the pipeline
- Use explain() — Check if your pipeline uses indexes with
.explain("executionStats") - allowDiskUse — For large datasets, enable disk-based processing to avoid 100MB memory limit
- $merge for caching — Pre-compute expensive aggregations on a schedule
The aggregation pipeline is not just a query tool — it is a complete data processing engine. Master it, and you can replace significant chunks of application-level data processing with efficient, index-aware database operations.