Ch. 12 · MongoDB

MongoDB Aggregation Pipeline Stage Order

Why $match before $group can change your answer, what the MongoDB optimizer reorders for you, and how to order stages for speed safely.

~6 min readintermediateupdated Oct 7, 2026

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") }
])
JavaScript

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 } } }
JavaScript

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 } ]
JavaScript

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 }
])
JavaScript

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 $match that comes after a $project or $addFields is 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.
  • $sort followed by $limit is coalesced into a top-k sort, so the server keeps only limit documents in memory instead of sorting everything.
  • Adjacent $match stages merge, and adjacent $limit or $skip stages combine.
  • A $lookup followed by an $unwind of 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)
JavaScript

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)
}
JavaScript

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 }
])
JavaScript

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 }
])
JavaScript

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.

Further reading

More in MongoDB

read ✓MongoDB · hard

MongoDB Explain Plans and Examined Documents

How to read MongoDB explain output: the plan stages, keys and documents examined versus returned, and what each pattern tells you to fix.

~6 min readread →
read ✓MongoDB · mid

MongoDB Lookup and Relationship Cost

What $lookup really does per input document, why an index on the foreign field matters, and when to reshape data instead of joining.

~5 min readread →
esc