Why should $match come early in an aggregation pipeline, and when does MongoDB reorder it for you?
answer
- a pipeline is a stream, each stage pays per document
- only the front of a pipeline reaches the query layer
- the optimizer moves some filters, not all
- computed fields pin a $match in place
- explain() shows the pipeline after rewriting
basics
~20 sOnly a leading $match becomes an indexed query on the collection, so filtering first shrinks the stream before expensive stages run. MongoDB's optimizer moves $match ahead of $project, $addFields or $sort only when the filter uses no field those stages create.
solid answer
~50 sAn aggregation pipeline is a stream: every stage pays for each document it sees, so the cheapest work is work you never do. A `$match` at the head of the pipeline is special because it is handed to the query layer and can use an index; a `$match` further down runs against the in-memory document stream and can only filter what already got there. MongoDB's optimizer does part of this for you — it will push a `$match` ahead of `$project`, `$addFields`/`$set` and `$sort` when the predicate refers only to fields those stages did not create, it merges adjacent `$match` stages into a single `$and`, and it coalesces `$limit`/`$skip` pairs. What it cannot do is push a filter through a stage that computes the field being filtered: a `$match` on a `$group` accumulator, or on a field produced by `$lookup`, stays where you wrote it. So write the selective, indexable predicate first yourself, and use `explain()` to confirm the order the server actually ran.
code
javascript · 13 lines// Slow: every document is grouped, then almost all groups are discarded
db.orders.aggregate([
{ $group: { _id: "$customerId", total: { $sum: "$amount" } } },
{ $match: { total: { $gt: 1000 } } },
{ $match: { _id: { $ne: null } } }
])
// Fast: raw-field filter first (index-eligible), aggregate filter after
db.orders.aggregate([
{ $match: { status: "paid", createdAt: { $gte: ISODate("2026-01-01") } } },
{ $group: { _id: "$customerId", total: { $sum: "$amount" } } },
{ $match: { total: { $gt: 1000 } } }
])go deeper
Be ready to state the rule and the reason: put the filter first so fewer documents reach every later stage, and because only the first stage can use an index. Recognising a pipeline that filters too late is enough here.
Explain which rewrites the server performs and why a filter on a computed field cannot move. Name the coalescing rules for adjacent $match, $limit and $skip stages, and show how explain() reveals the optimized order.
Diagnose a real slow pipeline: identify the stage that sees too many documents, decide whether the fix is an earlier predicate, an index for the leading $match, or a filter pushed inside a $lookup sub-pipeline, and prove the change with before/after plans.
Own the standard: how pipelines get reviewed, whether analytics queries are allowed to scan whole collections, and when a recurring expensive aggregation should stop being optimized and start being materialized instead.
## Why stage order matters at all A pipeline is executed as a stream of documents flowing from stage to stage. Each stage's cost is roughly (per-document cost) x (documents that reach it). Putting a selective filter at the front therefore reduces the input to *every* downstream stage at once — that is the whole optimization in one sentence. Putting the same filter at the end means every expensive stage did its work on documents that were about to be thrown away. ## Only the leading stages can use an index There is a second, sharper reason, and it is the one that separates a middle answer from a junior one. When a pipeline begins with `$match`, the server hands that predicate to the ordinary query layer: it becomes a query on the collection and is eligible to use an index, exactly like the equivalent `find()`. The same is true of an initial `$sort` (it can be satisfied by an index's ordering) and of `$geoNear`, which must be first and uses a geospatial index. Once any stage has transformed the stream — `$group`, `$unwind`, a `$project` that computes fields, a `$lookup` that attaches an array — the documents flowing downstream are no longer collection documents in index order. A later `$match` is then a linear filter over whatever the stream contains. It still reduces work for stages after it, but nothing about it can touch an index. ## What the optimizer rewrites for you MongoDB's pipeline optimizer applies a set of semantics-preserving rewrites before execution: - **Filter pushdown past reshaping stages.** A `$match` following `$project`, `$unset`, `$addFields`/`$set` is split: the part of the predicate that touches only pre-existing fields is moved ahead of the reshaping stage; the part that touches computed fields stays behind. - **Filter pushdown past `$sort`.** Sorting does not change which documents exist, so a `$match` after a `$sort` can move in front of it — and that is a large win, because it shrinks the sort's input. - **Coalescing adjacent stages.** Two consecutive `$match` stages become one predicate joined with `$and`; consecutive `$limit` stages collapse to the smallest; consecutive `$skip` stages sum; a `$limit` following a `$sort` is folded into the sort so it keeps only the top *n*; a `$limit` after a `$skip` is rewritten so the limit runs first with its amount increased by the skip. - **Dependency (projection) analysis.** The pipeline works out which fields the later stages actually reference and avoids carrying the rest, which reduces the bytes moved per document. ## What it will not do for you The optimizer will not invent a filter, and it will not move one past a stage that produces the field being filtered. The classic case: ```javascript [ { $group: { _id: "$customerId", total: { $sum: "$amount" } } }, { $match: { total: { $gt: 1000 } } } ] ``` `total` does not exist before `$group`, so the `$match` cannot move. That is correct and unavoidable — but it means any filter on the *raw* documents (a date range, a status, a tenant id) is yours to place before the `$group`, and if you leave it after, you have grouped the whole collection for nothing. The same applies to a `$match` on a field that `$lookup` attached: filter the base collection first, and filter the joined side inside the join's own sub-pipeline rather than after it. ## Verify, do not assume `explain()` on an aggregation shows the pipeline *after* these rewrites, so you can read off whether your `$match` moved, whether the leading filter reached an index, and whether a `$limit` was folded into a `$sort`. If the shape in the explain output is the shape you typed, no rewrite happened, and any reordering you wanted has to be written by hand. ## Practical rules Write the most selective, index-backed predicate as stage one. Keep filtering on raw fields ahead of `$group`, `$unwind` and `$lookup`. Reserve post-`$group` `$match` for genuine predicates on aggregates (the equivalent of a HAVING clause). Trim fields early when documents are wide. And remember that a rewritten pipeline never changes results — if reordering a pipeline changed your output, you moved a filter across a stage that changed the field's meaning.
- Which reorderings does the optimizer apply that a candidate should be able to name?Splitting a `$match` and pushing the non-computed part ahead of `$project`, `$unset` or `$addFields`; moving a `$match` in front of a `$sort`; merging adjacent `$match` stages into one `$and`; collapsing consecutive `$limit` or `$skip` stages; folding a `$limit` that follows a `$sort` into the sort itself; and rewriting `$skip` followed by `$limit` so the limit runs first with its amount increased.
- You need to filter on a field the $lookup brings in. Where should that filter go?Inside the join rather than after it. Use `$lookup`'s pipeline form with `let` and put the predicate in the sub-pipeline, so the joined side is filtered as it is read instead of attaching every matching document and discarding most of them one stage later. Filtering the base collection first, before the `$lookup` runs at all, matters even more.
- Does putting $match first ever change a pipeline's results?Not if the predicate means the same thing in both positions — the optimizer only performs rewrites that provably preserve results. But a hand-move can change results: filtering on a field before a `$project` renamed it, or before an `$addFields` overwrote it, is filtering on a different value. If reordering changed your output, you crossed a stage that redefined the field.
It is the difference between checking tickets at the door and checking them after everyone has been seated, fed and entertained — same guest list either way, wildly different cost.
saying these in an interview costs you the question
- Assumes the optimizer always pushes every $match to the front
- Believes a $match anywhere in the pipeline can use an index
- Filters on a $group total before the $group and expects it to work
- Adds a post-$lookup $match instead of filtering inside the join
- Never checks explain() to see the pipeline the server actually ran