Finding documents is only half of what applications need. Dashboards and reports ask questions about groups of documents: total sales per month, average rating per product, the five best-selling items, the number of orders in each status. Pulling every document into your application to add them up is slow and wasteful. MongoDB's aggregation pipeline does the work inside the database.
A pipeline is an array of stages. Documents flow through the stages in order, and each stage transforms them: $match filters, $group combines documents into groups and calculates totals, $sort orders them, $limit keeps the first few and $project reshapes each one. The output of one stage is the input of the next, like an assembly line.
Aggregation is MongoDB's equivalent of SQL's GROUP BY, SUM, AVG and much more. In this lesson you'll build pipelines step by step with the most important stages and accumulators. The next lesson adds stages for arrays, buckets and multiple results. The examples use this sales collection:
db.sales.insertMany([
{ _id: 1, item: 'Mug', category: 'kitchen', qty: 2, price: 8.5, month: 1, region: 'North' },
{ _id: 2, item: 'Lamp', category: 'home', qty: 1, price: 24, month: 1, region: 'South' },
{ _id: 3, item: 'Mug', category: 'kitchen', qty: 5, price: 8.5, month: 2, region: 'North' },
{ _id: 4, item: 'Kettle', category: 'kitchen', qty: 1, price: 30, month: 2, region: 'South' },
{ _id: 5, item: 'Rug', category: 'home', qty: 1, price: 80, month: 2, region: 'North' },
{ _id: 6, item: 'Lamp', category: 'home', qty: 3, price: 24, month: 3, region: 'North' }
])Pipelines and stages
db.sales.aggregate([
{ $match: { region: 'North' } }, // 1. keep North sales
{ $group: { _id: '$category', units: { $sum: '$qty' } } }, // 2. total units per category
{ $sort: { units: -1 } } // 3. biggest first
])aggregatetakes an array of stages and returns a cursor of results.'$fieldName', a string starting with$, refers to a field's value in the current document.- Put
$matchfirst whenever you can: it reduces the documents later stages handle, and at the start of a pipeline it can use indexes. - Stages can repeat and appear in any order that makes sense.
$group and accumulators
$group collects documents with the same _id value into one result document:
| Accumulator | Result per group |
|---|---|
$sum: '$qty' | Total of a field |
$sum: 1 | Number of documents |
$avg: '$price' | Average |
$min, $max | Smallest and largest value |
$first, $last | Value from the first or last document (after sorting) |
$push: '$item' | Array of every value |
$addToSet: '$item' | Array of unique values |
Group by several fields with an object: _id: { month: '$month', region: '$region' }. Use _id: null to calculate totals across all documents. Accumulators can work on expressions too: { $sum: { $multiply: ['$qty', '$price'] } } totals revenue.
Reshaping with $project and $set
$projectchooses and computes the fields of each output document:{ $project: { _id: 0, category: '$_id', revenue: { $round: ['$revenue', 2] } } }.$set(also called$addFields) adds or replaces fields and keeps all the others.$sort,$limitand$skipwork like theirfindequivalents, and$countoutputs the number of documents reaching it.- A
$matchafter$groupfilters groups, like SQL'sHAVING.
What $group doesn't promise, and memory limits
$group returns its groups in no particular order. On a small collection you might see the same order every time, but it isn't guaranteed and can change between runs or versions. If order matters, for a report or a chart, always add a $sort after $group, for example { $sort: { _id: 1 } } to sort by the grouping key.
The same goes for $first and $last: they mean "first or last document the group received", so they're only meaningful when a $sort stage comes before the $group. Without one, the order documents arrive in depends on how they happen to be stored.
Stages that have to hold many documents at once, such as $group and $sort, can use up to 100 MB of memory each. If they need more, MongoDB writes temporary files to disk. Since MongoDB 6.0 that happens automatically, because the server setting allowDiskUseByDefault is true. You can still control it per query:
db.sales.aggregate([...], { allowDiskUse: false }) // fail instead of spilling to diskSpilling to disk keeps big pipelines working but makes them slower. When a pipeline regularly needs it, look for ways to shrink its input first: a stricter $match, a $project that drops large fields early, or a summary collection kept up to date with $merge.
MongoDB Aggregation Syntax
Aggregation essentials
db.c.aggregate([
{ $match: { status: 'paid' } },
{ $group: { _id: '$customerId', orders: { $sum: 1 }, spent: { $sum: '$total' } } },
{ $match: { spent: { $gt: 100 } } },
{ $sort: { spent: -1 } },
{ $limit: 5 },
{ $project: { _id: 0, customerId: '$_id', orders: 1, spent: { $round: ['$spent', 2] } } }
])
{ $group: { _id: null, total: { $sum: '$amount' } } }
{ $group: { _id: { y: { $year: '$at' }, m: { $month: '$at' } }, n: { $sum: 1 } } }MongoDB Aggregation Summary
- aggregate([...]) runs documents through a pipeline of stages in order.
- $match filters (put it first to use indexes); $group combines documents by _id.
- Accumulators such as $sum, $avg, $min, $max, $push and $addToSet calculate values per group.
- '$field' refers to a field's value; _id: null groups everything together.
- $project and $set reshape documents; $sort and $limit give top-N results.
MongoDB Aggregation Examples
Each example shows the code, the output it produces and what each part does.
Revenue per category
db.sales.aggregate([
{ $group: {
_id: '$category',
orders: { $sum: 1 },
units: { $sum: '$qty' },
revenue: { $sum: { $multiply: ['$qty', '$price'] } }
} },
{ $sort: { revenue: -1 } }
])[
{ _id: 'home', orders: 3, units: 5, revenue: 176 },
{ _id: 'kitchen', orders: 3, units: 8, revenue: 89.5 }
]Line-by-line explanation
- Line 3Documents with the same category end up in one group.
- Line 4$sum: 1 counts the documents in each group.
- Line 5$sum of a field adds up its values.
- Line 6Accumulators can use expressions, here quantity times price.
- Line 8Sort the groups by revenue, highest first.
Filtering, top items and totals
db.sales.aggregate([
{ $match: { region: 'North' } },
{ $group: { _id: '$item', units: { $sum: '$qty' } } },
{ $sort: { units: -1, _id: 1 } },
{ $limit: 2 }
])
db.sales.aggregate([
{ $group: { _id: null, totalUnits: { $sum: '$qty' }, avgPrice: { $avg: '$price' }, maxPrice: { $max: '$price' } } },
{ $project: { _id: 0, totalUnits: 1, avgPrice: { $round: ['$avgPrice', 2] }, maxPrice: 1 } }
])[ { _id: 'Mug', units: 7 }, { _id: 'Lamp', units: 3 } ]
[ { totalUnits: 13, maxPrice: 80, avgPrice: 29.17 } ]Line-by-line explanation
- Line 2$match first keeps only North sales, reducing work for later stages.
- Lines 4 to 5Sorting then limiting gives a top-N list; _id breaks ties.
- Line 9_id: null puts every document in one group for overall totals.
- Line 10$project hides _id and rounds the average to two decimal places.
Grouping by several fields with $push
db.sales.aggregate([
{ $group: {
_id: { month: '$month', region: '$region' },
items: { $push: '$item' },
distinctItems: { $addToSet: '$item' }
} },
{ $match: { 'items.1': { $exists: true } } },
{ $project: { _id: 0, month: '$_id.month', region: '$_id.region', items: 1 } },
{ $sort: { month: 1 } }
])[ { items: [ 'Mug', 'Rug' ], month: 2, region: 'North' } ]Line-by-line explanation
- Line 3An object as _id groups by month and region together.
- Line 4$push collects every item in the group into an array.
- Line 5$addToSet collects unique items only.
- Line 7A $match after $group filters groups, like SQL's HAVING: here, groups with at least two sales.
- Line 8$project flattens the compound _id into separate fields.
Common MongoDB Aggregation Mistakes
Forgetting the $ on field paths
{ $group: { _id: 'category', total: { $sum: 'qty' } } } // groups by the literal text "category"; total is 0{ $group: { _id: '$category', total: { $sum: '$qty' } } }Filtering after grouping when you could filter first
[{ $group: { _id: '$region', n: { $sum: 1 } } }, { $match: { _id: 'North' } }] // groups every document first[{ $match: { region: 'North' } }, { $group: { _id: '$region', n: { $sum: 1 } } }]Totalling in application code
let total = 0
db.sales.find().forEach(s => { total += s.qty * s.price }) // moves every document over the networkdb.sales.aggregate([{ $group: { _id: null, total: { $sum: { $multiply: ['$qty', '$price'] } } } }])MongoDB Aggregation in Real Projects
Aggregation in practice
Admin dashboards show revenue per day, top products and orders per status with aggregation pipelines. Analytics features compute averages, counts and leaderboards, and scheduled jobs build summary collections with $merge so dashboards load instantly. Atlas Charts and many BI tools read MongoDB through pipelines, and the same pipelines run from Node.js with collection.aggregate([...]).toArray().
MongoDB Aggregation FAQ
What is the difference between find and aggregate in MongoDB?
find filters, projects, sorts and pages documents, returning them more or less as they're stored. aggregate runs a pipeline of stages that can also group, compute new fields, unwind arrays, join collections and write results to another collection. If find can answer your question, use it; reach for aggregate when you need totals, reshaping or joins.
How do I count documents per group in MongoDB?
Group by the field and add $sum: 1 for each document: db.orders.aggregate([{ $group: { _id: '$status', count: { $sum: 1 } } }]). The $sortByCount: '$status' stage does the same and also sorts by count, largest first. Add { $sort: { _id: 1 } } if you'd rather list the groups alphabetically.
How do I group by date in a MongoDB aggregation?
Turn each date into the period you want and group on that. { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, total: { $sum: '$amount' } } } totals per day. For weeks or months, $dateTrunc with unit: 'week' or 'month' keeps a real Date as the key, and both operators accept a timezone so days follow local time.
How do I filter results after grouping, like HAVING in SQL?
Add a $match stage after the $group and filter on the fields it produced: { $match: { count: { $gt: 10 } } }. A $match before $group filters input documents, like WHERE, while one after filters the groups, like HAVING. Use both when you need them.
Can an aggregation pipeline use indexes?
Yes, but mainly at the start. A $match and $sort at the beginning of a pipeline can use indexes just like a find. Once documents have been reshaped by stages such as $group, $project or $unwind, later stages work on the new documents in memory, so put your filtering first.
MongoDB Aggregation Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Complete the stage that keeps only paid orders.
db.orders.aggregate([{ : { status: 'paid' } }])
What does $group's _id decide?
Using the sales collection, how many groups does this produce?
db.sales.aggregate([{ $group: { _id: '$month' } }])
How do you count the documents in each group?
Why should $match usually come first?
MongoDB Aggregation Quiz
5 questions to check what you learned.
What is an aggregation pipeline?
- A backup process
- A type of index
- A replication method
- An array of stages that documents pass through in order
What does _id: null in $group do?
- Removes _id from results
- Causes an error
- Groups all documents into a single group
- Groups by missing values only
Which accumulator builds an array of unique values?
- $push
- $unique
- $distinct
- $addToSet
What does a $match placed after $group act like in SQL?
- HAVING
- WHERE
- ORDER BY
- JOIN
What does '$price' mean inside a pipeline stage?
- The text "price"
- The value of the price field in the current document
- An operator called price
- The average price