skip to content

Pipeline Optimization & Output

Why stage order matters, when the server rewrites it for you, and where a pipeline spills to disk or dies. The senior-level signal is putting $match first and knowing which stages can still use an index.

part ofMongoDBoverview, primer and where to startread it →
on this pageshow

questions

6

Why should $match come early in an aggregation pipeline, and when does MongoDB reorder it for you?

level: middleimportance: must knowfreq 76%

answer

  1. a pipeline is a stream, each stage pays per document
  2. only the front of a pipeline reaches the query layer
  3. the optimizer moves some filters, not all
  4. computed fields pin a $match in place
  5. explain() shows the pipeline after rewriting

basics

~20 s

Only 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 s

An 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
javascript
// 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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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

context

open as a page

A $group over 300 million documents spills to disk and runs for an hour — how would you speed it up?

level: seniorimportance: must knowfreq 60%

basics

~20 s

Cut what reaches the $group: an index-backed $match at the head of the pipeline, fewer carried fields, and a coarser grouping key. $group memory scales with distinct keys and accumulator state, so if the key is genuinely huge, split the work by time window and materialize partial results incrementally.

open as a page

What does allowDiskUse: true do in a MongoDB aggregate() call?

level: juniorimportance: should knowfreq 58%

basics

~20 s

allowDiskUse: true lets blocking aggregation stages such as $sort and $group write temporary files on disk when their working set exceeds the 100 MB per-stage memory budget, instead of the pipeline failing with a memory-limit error.

open as a page

How do $out and $merge differ when writing aggregation results to a collection?

level: middleimportance: should knowfreq 50%

basics

~20 s

$out replaces a target collection wholesale with the pipeline's results, keeping the collection's existing indexes. $merge writes incrementally into an existing collection, matching on the on field and applying whenMatched and whenNotMatched rules, so it can update in place rather than rebuilding.

open as a page

When can a $sort in an aggregation pipeline use an index, and what does a following $limit change?

level: middleimportance: should knowfreq 52%

basics

~20 s

A $sort can use an index only while the pipeline is still reading collection documents — at the head, optionally after a leading $match the same index supports. A $limit right after $sort is folded into it, so only the top n documents are held instead of the whole input.

open as a page

What does explain() on an aggregation pipeline show that the pipeline text does not?

level: seniorimportance: should knowfreq 45%

basics

~20 s

Explain output shows the optimized pipeline — the stages array after reordering and coalescing — plus a leading $cursor stage holding the query plan for the part the query layer executed. On a sharded cluster it also shows how the pipeline was split between shards and the merging node.

open as a page