MongoDB Aggregation Pipeline
Writing pipelines that use indexes, the stages that force everything into memory, and how to read their explain output.
The aggregation pipeline transforms documents through a sequence of stages. Its performance is decided almost entirely by whether the first stages can use an index.
db.orders.aggregate([
// 1. Filter first, on an indexed field.
{ $match: { status: "paid", createdAt: { $gte: ISODate("2026-07-01") } } },
// 2. Drop fields you do not need, before they are carried through.
{ $project: { customerId: 1, totalCents: 1, createdAt: 1 } },
{ $group: { _id: "$customerId", revenue: { $sum: "$totalCents" }, orders: { $sum: 1 } } },
{ $sort: { revenue: -1 } },
{ $limit: 20 }
]);Order the stages deliberately
$match first, always. Only a $match at the start of the pipeline can use an index. Once a
$group or $unwind has run, later $match stages filter in memory.
$project or $unset early to stop carrying fields through every subsequent stage.
$sort before $group only if the sort can use an index; otherwise sort at the end on the
smaller result.
$limit as early as correctness allows. MongoDB can push a $sort plus $limit into a
top-k selection rather than sorting everything.
Stages that consume memory
$lookup deserves specific caution: it runs a query against the foreign collection for each input
document unless the join can use an index on the foreign key. Always index the foreign field, and
treat a $lookup on a hot path as a modelling question — see
Document Modeling.
$unwind on a large array multiplies the document count for every subsequent stage. Filter before
unwinding, not after.
Reading explain
db.orders.explain("executionStats").aggregate(pipeline);Look for:
IXSCANin the first stage. ACOLLSCANmeans the leading$matchis not indexed.totalDocsExaminedversusnReturned. A large gap means the index is not selective.hasSortStageorSORTin the plan — an in-memory sort that an index could have avoided.spilledRecordsor disk usage in the execution stats.
Useful stages
// $facet: several aggregations over the same input in one pass.
db.orders.aggregate([
{ $match: { createdAt: { $gte: ISODate("2026-07-01") } } },
{ $facet: {
byStatus: [ { $group: { _id: "$status", n: { $sum: 1 } } } ],
revenue: [ { $group: { _id: null, total: { $sum: "$totalCents" } } } ],
topOrders: [ { $sort: { totalCents: -1 } }, { $limit: 5 } ]
}}
]);
// $merge: write results into a collection, for scheduled rollups.
db.orders.aggregate([
{ $match: { createdAt: { $gte: since } } },
{ $group: { _id: { d: { $dateTrunc: { date: "$createdAt", unit: "day" } } },
revenue: { $sum: "$totalCents" } } },
{ $merge: { into: "daily_revenue", on: "_id", whenMatched: "replace" } }
]);$merge is how you build incremental rollups: run it on a schedule over a recent window, and the
expensive aggregation stops being on the read path.