How it works
The explanation, with examples you can run.
A pipeline
Units and revenue per SKU for paid orders in September, top three:
db.orders.aggregate([ { $match: { status: "paid", createdAt: { $gte: ISODate("2026-09-01"), $lt: ISODate("2026-10-01") } } }, { $unwind: "$items" }, { $group: { _id: "$items.sku", units: { $sum: "$items.qty" }, revenueCents: { $sum: "$totalCents" } } }, { $sort: { units: -1 } }, { $limit: 3 }])[ { "_id": "sku-218", "units": 62, "revenueCents": 196683 }, { "_id": "sku-293", "units": 62, "revenueCents": 238716 }, { "_id": "sku-184", "units": 58, "revenueCents": 325402 }](Synthetic data, one item per order, so revenueCents here is the order total.) $match selects documents, $unwind emits one document per array element, $group folds documents with the same _id into one using accumulators ($sum, $avg, $max, $push, $addToSet), and $sort with $limit keeps the top three. Because the sort is followed by a limit, the server only keeps the top three in memory while sorting.
Indexes only help at the start
With an index on { status: 1, createdAt: 1 }, explaining a pipeline that starts with that $match shows the familiar plan at the bottom:
GROUP <- FETCH <- IXSCANOnce documents have passed through $group, $unwind or $project, they are new documents in memory and no index can help. So:
- Start with
$matchon indexed fields, then$sortif the order matters. - Use
$projector$unsetearly to drop large fields you do not need. - Put
$limitas early as the logic allows.
The optimiser reorders some stages itself (it can move a $match ahead of a $project that does not change the matched fields, and merge $sort with $limit), but writing them in the right order is clearer.
Joins and memory
$lookup adds matching documents from another collection as an array, one lookup per input document, so index the foreign field. It is useful, but if every page view needs one, the schema may want an embedded copy instead.
Blocking stages ($sort without an index, $group, $bucketAuto, $setWindowFields) hold data in memory. Each stage may use 100 MB; above that it writes temporary files, which allowDiskUseByDefault permits from 6.0 onwards. MongoDB 9.0 adds a limit per operation (by default the larger of 1 GB and 20% of the server's memory), so a query that used to run slowly with huge memory now fails with ExceededMemoryLimit.
To write results somewhere, end with $out (replace a collection) or $merge (upsert into one), which is how materialised summaries are built on a schedule.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.