The aggregation pipeline
Stages as a sequence of transformations, the accumulators worth knowing, and how to keep a pipeline eligible for indexes.
Stages as transformations
A pipeline is an array of stages; each takes the documents produced by the previous stage and emits new ones. Read it top to bottom, because that is also the order the server executes it in — and therefore the order that decides whether an index can be used.
db.orders.aggregate([
{ $match: { placedAt: { $gte: ISODate("2026-01-01") }, status: "paid" } },
{ $unwind: "$items" },
{ $group: {
_id: { sku: "$items.sku" },
units: { $sum: "$items.qty" },
revenue:{ $sum: { $multiply: ["$items.qty", "$items.unitPrice"] } },
orders: { $addToSet: "$_id" }
} },
{ $project: {
_id: 0,
sku: "$_id.sku",
units: 1,
revenue: 1,
orderCount: { $size: "$orders" }
} },
{ $sort: { revenue: -1 } },
{ $limit: 10 }
]);| Stage | Purpose |
|---|---|
$match | Filter documents — put it first so later stages see less data |
$project / $addFields | Shape documents: keep, rename, drop or compute fields |
$group | Collapse by key with accumulators |
$unwind | Expand one array into one document per element |
$lookup | Left outer join to another collection |
$facet | Run several sub-pipelines over the same input for one result page |
$merge / $out | Write results to a collection — materialised summaries |
$sort / $limit / $skip | Order and page; $sort before $limit is mandatory for determinism |
Accumulators and shaping
db.orders.aggregate([
{ $match: { status: { $ne: "cancelled" } } },
{ $addFields: { units: { $sum: "$items.qty" } } },
{ $group: {
_id: { $dateTrunc: { date: "$placedAt", unit: "day" } },
orders: { $sum: 1 },
avgUnits: { $avg: "$units" },
first: { $first: "$customer.name" },
last: { $last: "$customer.name" },
skus: { $push: "$items.sku" }
} },
{ $unwind: { path: "$skus", preserveNullAndEmptyArrays: true } },
{ $sort: { _id: -1 } }
]);$sum,$avg,$min,$maxaggregate numbers;$sum: 1counts documents.$pushkeeps duplicates and can grow the document;$addToSetremoves them but is unordered.$firstand$lastdepend on the incoming order, so sort before grouping if you care.- Without
preserveNullAndEmptyArrays,$unwindsilently drops documents whose array is missing or empty. - Each stage has a 100 MB memory budget; add
allowDiskUse: truewhen a large$sortor$groupexceeds it.
Keeping a pipeline fast
db.orders.explain("executionStats").aggregate([
{ $match: { status: "paid", placedAt: { $gte: ISODate("2026-01-01") } } },
{ $group: { _id: "$customer.id", total: { $sum: 1 } } }
]);- Only a
$matchor$sortat the start of the pipeline can use an index — after$groupor$projectthe planner is working with computed documents. - In
executionStats, look forIXSCANversusCOLLSCAN, and comparetotalKeysExaminedandtotalDocsExaminedagainstnReturned. $lookuprequires an index on the foreign field; without it, every input document triggers a collection scan on the other side.- Use
$facetto return results plus counts plus filter options in one pass instead of three round trips.
⚠️
A pipeline that starts with
$project or $addFields throws away index eligibility for every stage after it. Filter first, compute later.FAQ
How do I count distinct values?
Group by the field and then count groups, or use
$addToSet inside a group and take $size of the result. db.collection.distinct() is simpler but returns the whole set to the client.Should I use aggregation or a view?
A read-only view is a stored pipeline — useful when many callers need the same shape. If the input is large and the result is small, write it to a collection with
$merge and refresh on a schedule.Related
Documents, collections and queries Indexes and schema design patterns
Last refreshed 2026-09-18.