What you'll learn
- Quick answer
- What is the MongoDB aggregation pipeline?
- Our sample data: an orders collection
- $match: keep only the rows you want
- $group: bundle rows and do the maths
- $sort, $project and $limit: order, reshape, trim
- Putting the whole pipeline together
- The five stages at a glance
- Common gotchas and our recommendation
- FAQ
Quick Answer
The MongoDB aggregation pipeline is a series of stages that transform your documents one step at a time, where the output of each stage feeds into the next. The most common stages are $match (filter), $group (bundle and calculate), $sort (order), $project (reshape) and $limit (trim). Chain them and you can answer questions like "how much has each customer spent?" that a plain find() cannot. Put $match first so you filter before doing the heavy work.
What is the MongoDB aggregation pipeline?
If you have used db.collection.find() in MongoDB, you can already read and filter documents. But what if the question is "how much has each customer spent?" or "which customers ordered the most?" A plain find() cannot add things up or group them. That is exactly what the MongoDB aggregation pipeline is for.
Think of a pipeline as an assembly line for your data. Your documents enter at one end, pass through a series of stages, and each stage does one small job — filter, group, sort, reshape — before handing its result to the next stage. The output of one stage becomes the input of the next, just like the | pipe in a Linux terminal.
You write a pipeline as an array of stages inside aggregate():
db.orders.aggregate([
{ stage1 },
{ stage2 },
{ stage3 }
])MongoDB runs the stages strictly top to bottom. In this tutorial we will meet the five stages you will use most — $match, $group, $sort, $project and $limit — and chain them to answer one real question.
Our sample data: an orders collection
Every example below runs against one small collection called orders. Each document is a single order with a customer name, an item, an amount in rupees, and a status. Paste this into mongosh to follow along — if MongoDB is brand new to you, our free MongoDB course covers documents and collections from zero.
db.orders.insertMany([
{ customer: "Aarav", item: "Notebook", amount: 250, status: "delivered" },
{ customer: "Aarav", item: "Pen", amount: 50, status: "delivered" },
{ customer: "Aarav", item: "Bag", amount: 700, status: "delivered" },
{ customer: "Diya", item: "Bottle", amount: 300, status: "delivered" },
{ customer: "Diya", item: "Charger", amount: 600, status: "cancelled" },
{ customer: "Rohan", item: "Lamp", amount: 400, status: "delivered" }
])Notice two things we will use later: Diya has one cancelled order, and Aarav has three orders while the others have one. Our goal for this tutorial: find the top spenders and how many delivered orders each of them placed.
$match: keep only the rows you want
The $match stage filters documents. It uses the exact same query syntax as find(), and it passes through only the documents that match — everything else is dropped before the next stage sees it.
We only care about completed sales, so we drop cancelled orders:
db.orders.aggregate([
{ $match: { status: "delivered" } }
])That leaves five documents (Diya's cancelled charger is gone):
{ customer: "Aarav", item: "Notebook", amount: 250, status: "delivered" }
{ customer: "Aarav", item: "Pen", amount: 50, status: "delivered" }
{ customer: "Aarav", item: "Bag", amount: 700, status: "delivered" }
{ customer: "Diya", item: "Bottle", amount: 300, status: "delivered" }
{ customer: "Rohan", item: "Lamp", amount: 400, status: "delivered" }Always put $match as early as possible. Filtering first means every later stage has fewer documents to churn through, and an early $match can even use an index. This is the single biggest performance habit in aggregation.
$group: bundle rows and do the maths
The $group stage is where aggregation earns its name. It collapses many documents into one document per group. You choose the grouping key with _id, and then use accumulator operators like $sum to calculate across each group.
We want one row per customer, so the group key is the customer name. Note the $ in "$customer" — that means "the value of the customer field", not the literal word.
db.orders.aggregate([
{ $match: { status: "delivered" } },
{ $group: {
_id: "$customer",
orderCount: { $sum: 1 },
totalSpent: { $sum: "$amount" }
} }
])Two accumulators are doing work here. { $sum: 1 } adds 1 for every document in the group, which counts the orders. { $sum: "$amount" } adds up the amount field. The result is one document per customer:
{ _id: "Aarav", orderCount: 3, totalSpent: 1000 }
{ _id: "Diya", orderCount: 1, totalSpent: 300 }
{ _id: "Rohan", orderCount: 1, totalSpent: 400 }Two things to remember. First, the group key is always returned as _id in the output — we will rename it to something friendlier soon. Second, $group keeps only the fields you build here; the original item and status fields are gone. And the order of these groups is not guaranteed, which is why the next stage sorts them.
$sort, $project and $limit: order, reshape, trim
Three smaller stages finish the job. Each does one clear thing.
$sort orders the results
$sort arranges documents by a field. Use -1 for descending (highest first) and 1 for ascending (lowest first). We want the biggest spenders on top:
{ $sort: { totalSpent: -1 } }$project reshapes each document
$project chooses which fields appear and can rename them. A 1 keeps a field, a 0 removes it. Here we hide the raw _id and expose the group key as a friendly customer field:
{ $project: { _id: 0, customer: "$_id", orderCount: 1, totalSpent: 1 } }$limit trims to the top few
$limit keeps only the first N documents and drops the rest. After sorting, this gives you a clean "top N" list. To keep only the two biggest spenders:
{ $limit: 2 }On their own these are simple. The real power is chaining them in the right order, which we do next.
Putting the whole pipeline together
Here is the complete pipeline — all five stages, in order. Read it top to bottom like a recipe: filter, group, sort, reshape, trim.
db.orders.aggregate([
{ $match: { status: "delivered" } },
{ $group: {
_id: "$customer",
orderCount: { $sum: 1 },
totalSpent: { $sum: "$amount" }
} },
{ $sort: { totalSpent: -1 } },
{ $project: { _id: 0, customer: "$_id", orderCount: 1, totalSpent: 1 } },
{ $limit: 2 }
])And the final output — the top two spenders, cleanly labelled:
{ customer: "Aarav", orderCount: 3, totalSpent: 1000 }
{ customer: "Rohan", orderCount: 1, totalSpent: 400 }Trace it in your head: $match drops the cancelled order (6 documents become 5), $group collapses them into 3 customers, $sort puts Aarav (1000) above Rohan (400) above Diya (300), $project renames _id to customer, and $limit keeps the first two. Remove the $limit line and you would see all three customers — a full "total orders per customer" report.
The five stages at a glance
Keep this table handy. These five stages cover the large majority of everyday aggregation work.
| Stage | What it does | In our pipeline |
|---|---|---|
| $match | Filters documents (same syntax as find) | Keep only delivered orders |
| $group | Groups by a key and runs accumulators like $sum | One row per customer with counts and totals |
| $sort | Orders documents (1 up, -1 down) | Biggest spenders first |
| $project | Chooses and renames fields | Show customer, hide the raw _id |
| $limit | Keeps only the first N documents | Top 2 spenders |
There are many more stages — $lookup for joins, $unwind for arrays, $count, $addFields and others — but once these five make sense, the rest follow the same pattern.
Common gotchas and our recommendation
Watch out for these
- Forgetting the $ prefix.
"$customer"means the field's value;"customer"is the literal text. Mixing these up is the most common beginner bug. - Matching too late. Putting
$matchafter$groupmakes MongoDB group every document first and then throw work away. Filter early. - Expecting old fields after $group. A group only outputs its
_idand the accumulators you define. If you need a field later, rebuild it in the group or add it back with$project. - Relying on order without $sort. Group output has no guaranteed order. If order matters, add an explicit
$sort. - Sorting after limiting. For a "top N" list,
$sortmust come before$limit, or you will trim first and sort a random handful.
Our recommendation: build pipelines one stage at a time. Add $match, run it, check the output; add $group, run it again; and so on. Because each stage just feeds the next, you can always see exactly where a result went wrong. Start with the five stages here, practise them on real data in the free Priodemy MongoDB course, and you will be able to answer almost any reporting question your app throws at you.
Frequently Asked Questions
What is the difference between find() and the aggregation pipeline?
find() reads and filters documents but cannot combine them, so it returns them roughly as they are stored. The aggregation pipeline can filter, group, calculate totals and averages, sort, reshape and even join data across many stages. Use find() to fetch documents and aggregate() when you need a computed answer like totals per customer.
Why should $match come before $group?
$match filters documents out early, so every later stage has fewer documents to process, and an early $match can use an index. If you group first and filter afterwards, MongoDB does the expensive grouping on data it is about to throw away. As a rule, filter as early as possible in the pipeline.
What does _id mean inside a $group stage?
Inside $group, _id is the grouping key, the field whose value defines each group. Setting _id to "$customer" creates one output document per unique customer. It is unrelated to the document _id in your collection, and you can rename it to something friendlier later with $project.
Can I use the aggregation pipeline from Node.js or Mongoose?
Yes. A pipeline is just an array of stage objects, so the same code works in the MongoDB Node.js driver with collection.aggregate([...]) and in Mongoose with Model.aggregate([...]). The stages and syntax are identical to what you type in mongosh.
Is the aggregation pipeline slow?
Not if you use it well. A pipeline that starts with a $match on an indexed field and keeps only the fields it needs is very fast, even on large collections. Performance problems usually come from filtering too late or grouping huge amounts of data that could have been trimmed first.
