Lesson 0005 · Aggregation
MongoDB's real query language for anything past a simple find. A stream of documents flows through ordered stages — and the last stage you reach for, $lookup, is the join the document model spent lesson 0001 trying to avoid.
A plan shows stage: COLLSCAN, nReturned: 2, totalDocsExamined: 50000. The verdict:
MongoDB's classic planner picks the winning plan by:
In a plan, an IXSCAN with no FETCH stage means the query was:
A find() filters and returns documents. The moment you need to
compute — group and count, join, reshape, bucket by month — you reach for the
aggregation pipeline. Think of the Unix pipe you already use:
cat | grep | sort | uniq -c. Each command transforms a stream and hands it
on. MongoDB's pipeline is that idea for documents, and it maps almost one-to-one onto the
SELECT … WHERE … GROUP BY … ORDER BY you know — just written as an ordered
list of stages instead of one declarative statement.
A pipeline is one or more stages that process documents; each stage
operates on its input and passes its output to the next stage. A stage
need not emit one document per input — it can filter some out or synthesize new ones.
— MongoDB Manual: "An aggregation pipeline consists of one or more stages that process documents… The documents that a stage outputs are then passed to the next stage… A stage does not need to output one document for every input document."
The same stage can appear more than once (except $out, $merge,
$geoNear), and a pipeline is read-only unless it ends in
$out or $merge.
— MongoDB Manual: "The same stage can appear multiple times… except for $out, $merge, and $geoNear," and pipelines "do not modify documents… unless the pipeline contains a $merge or $out stage."
| Stage | Does | SQL equivalent |
|---|---|---|
$match | Filter documents by a condition | WHERE |
$group | Group by a key, compute aggregates ($sum, $avg) | GROUP BY |
$project | Choose / rename / compute fields | SELECT list |
$sort | Order the stream | ORDER BY |
$limit / $skip | Take / skip N | LIMIT / OFFSET |
$unwind | Explode an array → one doc per element | ≈ UNNEST |
$lookup | Join in documents from another collection | LEFT OUTER JOIN |
You write the stages in order, but MongoDB rewrites the pipeline for efficiency, and the
headline rewrite is one you already believe in: push the filter to the front.
Given a $sort followed by a $match, the optimizer moves
the $match before the $sort to shrink how much gets
sorted.
— MongoDB Manual: "When you have a sequence with $sort followed by a $match, the $match moves before the $sort to minimize the number of objects to sort."
Given a projection ($project/$addFields) followed by a
$match, it moves the parts of the filter that don't depend on computed fields
ahead of the projection — which lets the aggregation use an index on the
very first stage.
— MongoDB Manual: MongoDB "moves any filters in the $match stage that do not require values computed in the projection stage to a new $match stage before the projection… allowing the aggregation to use an index on the name field when initially querying the collection."
Filtering before sorting/joining/grouping is exactly the predicate pushdown
you saw the SQL planner do in the EXPLAIN lessons. The lesson for you:
put $match (and a $sort it can use) first, so
the pipeline's opening stage hits an index — the same first-stage index use you read in
explain() in 0004. A related
win: a $sort immediately followed by $limit is
coalesced into a top-N sort that keeps only N items in
memory.
— MongoDB Manual: "When a $sort precedes a $limit, the optimizer can coalesce the $limit into the $sort… This allows the sort operation to only maintain the top n results."
One thing you don't need to hand-tune: an early $project to drop
fields. The pipeline already figures out which fields it needs and stops carrying the rest.
— MongoDB Manual: "Using a $project stage… to reduce the number of fields… is unlikely to improve performance, because the database performs this optimization automatically."
$lookup performs a left outer join to another collection in the
same database, adding an array field to each input document that
holds the matching foreign documents.
— MongoDB Manual: "Performs a left outer join to a collection in the same database… The $lookup stage adds a new array field to each input document. The new array field contains the matching documents from the foreign collection."
It's the escape hatch for data you chose to reference rather than embed
(0001) — genuinely useful, but the manual is
blunt about the trade-off.
MongoDB's own guidance: "Excessive use of $lookup may slow down
query performance. To reduce reliance on $lookup, consider an embedded data
model."
— MongoDB Manual — $lookup
This is lesson 0001 returning as a diagnostic: if your pipelines are thick with
$lookup, that's a signal your schema is normalized like a relational one and
the data you keep joining probably wants to be embedded. A join is cheap in
Postgres because the whole engine is built around it; in MongoDB it's the thing good
schema design lets you avoid.
Because MongoDB fields can be arrays, one stage has no tidy relational equivalent:
$unwind takes a document with an N-element array and emits N
documents, one per element — flattening the array so you can $group or
$match on individual elements. It's the pipeline counterpart to the multikey
indexing you met in 0003: arrays are first-class,
so both the index layer and the query layer have a dedicated way to fan them out.
| Question | SQL (Postgres / MySQL) | MongoDB aggregation |
|---|---|---|
| Shape of a query | One declarative statement | An ordered list of stages |
| Filter / group / sort | WHERE / GROUP BY / ORDER BY | $match / $group / $sort |
| Join | Native, cheap, everywhere | $lookup — avoid via embedding |
| Who orders operations? | Planner, fully | You write order; optimizer still pushes $match early |
| Filter-early optimization | Predicate pushdown | $match moved before $sort / projection |
| Top-N | ORDER BY … LIMIT (top-N sort) | $sort + $limit coalesced |
Answer from memory — the effortful recall is what builds retention. Feedback is immediate.
Which aggregation stage is the equivalent of SQL GROUP BY with a count?
Why put $match as early as possible in a pipeline?
What does $lookup do?
Your pipelines lean heavily on $lookup. The best signal that suggests:
A $sort immediately followed by $limit: 10 is optimized how?
No Mongo handy yet, so this is a lab for later (Docker:
docker run --rm -p 27017:27017 mongo, then mongosh):
db.users.aggregate([
{ $match: { status: "active" } },
{ $group: { _id: "$city", n: { $sum: 1 } } },
{ $sort: { n: -1 } },
{ $limit: 5 }
])
db.users.aggregate([ /* same pipeline */ ], { explain: true }) // see the rewritten stage order
// With an index on { status: 1 }, the opening $match should IXSCAN, not COLLSCAN.
Read the explain output and find where MongoDB reordered your
stages — the $match pulled to the front, hitting the index. Bring it to your
teacher and we'll trace the rewrite together.
MongoDB Manual — Aggregation Pipeline
for the model, then Pipeline Optimization
(the $match-early and $sort+$limit rewrites), and
the $lookup
reference — read its performance note twice.