An aggregation pipeline is a conveyor belt. Each stage receives a stream of documents, does one job and hands a new stream to the next stage. That sounds simple, and it is, until you notice that the documents coming out of a $group look nothing like the ones going in. Most aggregation bugs, and most aggregation interview questions, come from losing track of what shape the documents have at a given point in the belt.
The examples below use mongosh against MongoDB 7 or 8. No MongoDB server was available when this guide was written, so the outputs are illustrative: they show what the documented behaviour produces, not a captured terminal session.
The shape changes at every stage
Take a small sales collection:
db.sales.insertMany([
{ customer: "ana", amount: 60, region: "eu", day: ISODate("2026-09-01") },
{ customer: "ana", amount: 60, region: "eu", day: ISODate("2026-09-03") },
{ customer: "ben", amount: 150, region: "us", day: ISODate("2026-09-02") },
{ customer: "cleo", amount: 40, region: "eu", day: ISODate("2026-09-04") }
])Before $group, each document is one sale and has fields customer, amount, region and day. After this stage:
{ $group: { _id: "$customer", total: { $sum: "$amount" }, sales: { $sum: 1 } } }each document is one customer and has only _id, total and sales. The field amount no longer exists. A $match: { amount: { $gt: 100 } } placed after that group matches nothing at all, and MongoDB won’t complain, because filtering on a missing field is perfectly legal. You just get an empty result. That silent empty result is the first thing to suspect when a pipeline “returns nothing”.
A useful habit: write the shape of the document as a comment next to each stage while you build the pipeline. It takes ten seconds and catches most mistakes before you run anything.
Moving a filter across $group changes the question
Here’s the classic trap. Someone asks for “customers who spent more than 100”. Two pipelines look similar:
// A: filter sales first, then group
db.sales.aggregate([
{ $match: { amount: { $gt: 100 } } },
{ $group: { _id: "$customer", total: { $sum: "$amount" } } }
])
// [ { _id: "ben", total: 150 } ]
// B: group first, then filter totals
db.sales.aggregate([
{ $group: { _id: "$customer", total: { $sum: "$amount" } } },
{ $match: { total: { $gt: 100 } } }
])
// [ { _id: "ben", total: 150 }, { _id: "ana", total: 120 } ]Pipeline A answers “which customers made a single sale over 100”. Pipeline B answers “which customers spent over 100 in total”. Ana made two sales of 60, so she appears only in B. Neither is wrong in general, but only one matches the requirement. If someone “optimizes” B into A because early filtering is faster, they’ve changed the business answer, and no test with one sale per customer would notice.
The SQL parallel helps if you know it: A is WHERE, B is HAVING. Interviewers like candidates who make that connection without being prompted.
Filters that can move safely are the ones on fields that don’t change meaning across the group. A filter on region is a good example when you group by customer and every customer lives in one region: dropping US sales before grouping or dropping US customers after grouping gives the same result, and filtering first does far less work. If a customer could buy from several regions, even that move becomes doubtful. You have to reason about the data, not just the syntax.
Why early stages matter for speed
Only stages at the very start of a pipeline can use an index. A leading $match behaves like a find filter and can use an index on the matched fields. A leading $sort (or a $match followed by $sort) can use an index to return documents already in order, which avoids an in-memory sort. Once a $group, $unwind or $project that computes new fields sits in front, the documents flowing through are new objects and no collection index applies to them.
So for a report on September EU sales, this order is right:
db.sales.aggregate([
{ $match: { region: "eu", day: { $gte: ISODate("2026-09-01"), $lt: ISODate("2026-10-01") } } },
{ $group: { _id: "$customer", total: { $sum: "$amount" } } },
{ $sort: { total: -1 } },
{ $limit: 10 }
])With an index on { region: 1, day: 1 }, the first stage reads only September EU entries. Everything after works on a small stream.
Memory is the other cost. Blocking stages such as $group and $sort hold data in memory, with a 100 MB per-stage limit. Since MongoDB 6.0 the server setting allowDiskUseByDefault is true, so a big stage spills to disk instead of failing, but spilling is slow. Reducing input before a blocking stage is how you avoid it.
What the optimizer does for you, and what it won’t
MongoDB rewrites pipelines before running them. A few rewrites you’ll see in explain output:
- A
$matchthat comes after a$projector$addFieldsis moved ahead of it when the match doesn’t depend on computed fields. If it filters on both original and computed fields, it’s split and the safe part moves forward. $sortfollowed by$limitis coalesced into a top-k sort, so the server keeps onlylimitdocuments in memory instead of sorting everything.- Adjacent
$matchstages merge, and adjacent$limitor$skipstages combine. - A
$lookupfollowed by an$unwindof the joined field is coalesced so the joined array is never built in full.
The rule behind all of these is that the optimizer only makes moves that preserve the result. It will never move a $match across a $group when the filter touches an accumulated field, because that changes the answer. So it won’t rescue Pipeline B from being slow, and it won’t turn Pipeline A into B either. Interviewers sometimes ask “doesn’t the optimizer handle stage order?” The honest answer is: partly, for semantics-preserving rewrites, and you should still write the efficient order yourself because it documents intent.
To see what actually ran, use explain:
db.sales.explain("executionStats").aggregate(pipeline)Look at the first stage: you want an IXSCAN under $cursor, not a COLLSCAN. Then check nReturned from that stage against the collection size.
Measuring intermediate cardinality
Final results hide what happened in the middle. A pipeline that produces 10 rows might push 40 million documents through an $unwind on the way. The simple technique is to cut the pipeline at a stage and count:
const stages = [ /* your pipeline */ ]
for (let i = 1; i <= stages.length; i++) {
const n = db.sales.aggregate([...stages.slice(0, i), { $count: "n" }]).toArray()[0]?.n ?? 0
print(i, Object.keys(stages[i - 1])[0], n)
}Run that on a realistic data sample. A sudden jump in the count tells you where the work is, and a sudden drop to zero tells you where a field name stopped existing. It’s crude and it works.
Interview practice
You’re given this pipeline for “top 5 product categories by revenue in the last 30 days”, and it’s slow on a 50-million-document orders collection that has an index on { createdAt: 1 }:
db.orders.aggregate([
{ $unwind: "$items" },
{ $group: { _id: "$items.category", revenue: { $sum: { $multiply: ["$items.price", "$items.qty"] } } } },
{ $match: { createdAt: { $gte: thirtyDaysAgo } } },
{ $sort: { revenue: -1 } },
{ $limit: 5 }
])What’s wrong, and how would you fix it?
Answer
There are two problems, and one of them is a correctness bug, not a speed issue. After $group, documents have only _id and revenue, so the $match on createdAt matches nothing. The pipeline returns an empty array, and if someone saw results before, it’s because the match was added later.
The fix is to move the date filter to the front, where it belongs both semantically (we want orders from the last 30 days, a row-level condition) and for performance (it can use the createdAt index):
db.orders.aggregate([
{ $match: { createdAt: { $gte: thirtyDaysAgo } } },
{ $unwind: "$items" },
{ $group: { _id: "$items.category", revenue: { $sum: { $multiply: ["$items.price", "$items.qty"] } } } },
{ $sort: { revenue: -1 } },
{ $limit: 5 }
])Now the $unwind runs only on recent orders. The $sort plus $limit pair is coalesced into a top-k sort over a handful of categories. To confirm, run explain and check that the first stage is an index scan on createdAt, and that the documents examined are close to the number of orders in the window. A good follow-up to mention: if the report runs constantly, consider adding a $project right after the match that keeps only items.category, items.price and items.qty, so less data flows through $unwind.