The Aggregation Pipeline
find() returns documents as they are stored. Reports need more: total sales per payment method, revenue per month, the top three customers. MongoDB answers these questions with the aggregation pipeline, a list of stages that documents flow through one after another, each stage filtering, grouping, reshaping or sorting them.
In this lesson you will build real sales reports for the shop with the most important stages: $match, $group, $sort, $limit, $project and $set. If you know SQL, think of it as WHERE, GROUP BY, ORDER BY, LIMIT and SELECT, written as steps. Start with freshly loaded data and use shop.
How a pipeline works
You call aggregate() with an array of stages. Each stage is a document whose single key names the stage. The output of one stage is the input of the next, like water through a series of filters:
db.orders.aggregate([
{ $group: { _id: '$status', orders: { $sum: 1 }, revenue: { $sum: '$total' } } },
{ $sort: { orders: -1, _id: 1 } }
])[
{ _id: 'delivered', orders: 7, revenue: 34038 },
{ _id: 'pending', orders: 2, revenue: 5597 },
{ _id: 'shipped', orders: 2, revenue: 6796 },
{ _id: 'cancelled', orders: 1, revenue: 2499 }
]The first stage, $group, puts orders with the same status into one group. The group key always goes in _id, and '$status' (with a dollar sign) means "the value of the status field". For each group it adds 1 per order to count them, and adds up the total fields. The second stage sorts the groups, with _id as a tie-breaker because pending and shipped both have two orders. Seven orders worth ₹34,038 have been delivered.
The stages you will use most often map neatly onto SQL:
| Stage | Job | SQL equivalent |
|---|---|---|
$match | Keep documents that match a filter | WHERE (or HAVING after a group) |
$group | Combine documents into groups and calculate totals | GROUP BY with aggregate functions |
$sort | Order documents | ORDER BY |
$limit / $skip | Keep the first n / skip n | LIMIT / OFFSET |
$project / $set | Choose, rename and calculate fields | The SELECT list |
$lookup | Bring in documents from another collection | JOIN |
$count | Count the documents that reach it | COUNT(*) |
$match first, then $group
Cancelled orders are not revenue, so filter them out before grouping. $match takes exactly the same filter as find(). Revenue by payment method:
db.orders.aggregate([
{ $match: { status: { $ne: 'cancelled' } } },
{ $group: {
_id: '$payment',
orders: { $sum: 1 },
revenue: { $sum: '$total' },
avgOrder: { $avg: '$total' }
} },
{ $set: { avgOrder: { $round: ['$avgOrder', 0] } } },
{ $sort: { revenue: -1 } }
])[
{ _id: 'card', orders: 2, revenue: 20997, avgOrder: 10498 },
{ _id: 'UPI', orders: 7, revenue: 19038, avgOrder: 2720 },
{ _id: 'COD', orders: 2, revenue: 6396, avgOrder: 3198 }
]UPI has by far the most orders, but card payments bring in more money because of one big monitor order. $set overwrote avgOrder with a rounded value. (Card's exact average is 10,498.5; $round rounds an exact half to the nearest even number, so it becomes 10,498.)
Putting $match first is also a performance rule. A $match at the start of a pipeline can use indexes, just like find(), and every document it removes is one less document for the later stages to process.
Accumulators in $group
The expressions inside $group are called accumulators, because they accumulate a value over the documents of each group:
| Accumulator | Result for each group |
|---|---|
$sum | Total of a field, or a count with $sum: 1 |
$avg | Average |
$min / $max | Smallest / largest value |
$count | Number of documents ({ $count: {} }) |
$push | Array of all values |
$addToSet | Array of distinct values |
$first / $last | Value from the first / last document (use after $sort) |
Use _id: null to treat the whole collection as one group, which gives grand totals:
db.orders.aggregate([
{ $match: { status: { $ne: 'cancelled' } } },
{ $group: { _id: null, revenue: { $sum: '$total' }, biggest: { $max: '$total' }, smallest: { $min: '$total' } } }
])[ { _id: null, revenue: 46431, biggest: 15999, smallest: 1298 } ]Top N: $sort and $limit after $group
The marketing team wants the three customers who spent the most:
db.orders.aggregate([
{ $match: { status: { $ne: 'cancelled' } } },
{ $group: { _id: '$customerId', spent: { $sum: '$total' }, orders: { $sum: 1 } } },
{ $sort: { spent: -1, _id: 1 } },
{ $limit: 3 }
])[
{ _id: 2, spent: 20997, orders: 2 },
{ _id: 1, spent: 11895, orders: 3 },
{ _id: 3, spent: 6197, orders: 2 }
]Customer 2 is Priya, customer 1 is Aarav and customer 3 is Kabir. To print their names in the report, you need data from the customers collection, which is what $lookup does in $lookup, $unwind & $facet.
A $match after $group filters groups, like SQL's HAVING. Adding { $match: { spent: { $gte: 5000 } } } after the group keeps only customers who spent at least ₹5,000. Note that after $group the documents have only the fields you created (_id, spent, orders), so later stages must use those names.
Grouping by month in Indian time
Dates are stored in UTC, but the shop's months are Indian months. Date operators accept a timezone, so an order placed at 1:55 am IST on 1 November counts in November even though it is still 31 October in UTC:
db.orders.aggregate([
{ $match: { status: { $ne: 'cancelled' } } },
{ $group: {
_id: { $dateToString: { format: '%Y-%m', date: '$date', timezone: 'Asia/Kolkata' } },
revenue: { $sum: '$total' },
orders: { $sum: 1 }
} },
{ $sort: { _id: 1 } }
])[
{ _id: '2026-08', revenue: 28693, orders: 4 },
{ _id: '2026-09', revenue: 4995, orders: 3 },
{ _id: '2026-10', revenue: 12743, orders: 4 }
]The group key can be any expression, not just a field. Here $dateToString turns each order date into text such as '2026-08', and orders with the same month land in the same group. Other date operators such as $year, $month and $dayOfWeek also take a timezone.
Reshaping with $project and $set
$project decides which fields leave the stage and can calculate new ones; $set (also called $addFields) adds or replaces fields and keeps everything else. A summary of Aarav's orders with the item count, 18% GST on the total and a size label:
db.orders.aggregate([
{ $match: { customerId: 1 } },
{ $project: {
_id: 0,
orderNo: '$_id',
status: 1,
items: { $size: '$items' },
gst: { $round: [{ $multiply: ['$total', 0.18] }, 0] },
size: { $cond: [{ $gte: ['$total', 5000] }, 'large', 'small'] }
} }
])[
{
status: 'delivered',
orderNo: 1001,
items: 2,
gst: 251,
size: 'small'
},
{
status: 'delivered',
orderNo: 1003,
items: 1,
gst: 1260,
size: 'large'
},
{
status: 'pending',
orderNo: 1011,
items: 1,
gst: 630,
size: 'small'
}
]orderNo: '$_id' renames a field, $size counts array elements, $multiply and $round do arithmetic and $cond works like an if-else: if the total is at least ₹5,000 the label is 'large', otherwise 'small'. Fields kept with 1 come first, in their stored order, and calculated fields follow in the order you wrote them.
Saving results and memory limits
A pipeline normally returns results to you, but it can also write them to a collection. $out replaces a collection with the results, and $merge adds or updates documents in one, which is handy for nightly report tables such as monthlySales. Either must be the last stage.
Each stage that holds data in memory, such as $group and $sort, is limited to 100 MB and writes temporary files to disk if it needs more, which is allowed by default. MongoDB 9.0 also caps the total memory one operation can use (by default 1 GB or 20% of the server's memory, whichever is greater), so a runaway report cannot starve everything else. Filtering early with $match and dropping unneeded fields with $project keeps pipelines well inside these limits.
Common mistakes
- Forgetting the
$before field names in expressions.{ $sum: 'total' }adds up the text "total" (which counts as 0); write'$total'. - Grouping before filtering. Put
$matchas early as possible, so indexes are used and fewer documents flow on. - Using old field names after
$group. Only_idand your accumulators exist afterwards. - Relying on group order.
$groupoutput comes in no particular order; always add$sort.
Key points
aggregate([...])runs documents through stages in order; each stage's output feeds the next.$matchfilters,$groupgroups by_idand calculates with accumulators,$sortand$limitorder and trim.- Inside expressions,
'$field'means the value of a field;_id: nullgroups everything together. $projectand$setrename, calculate and reshape fields with operators such as$multiply,$roundand$cond.- Date operators accept
timezone: 'Asia/Kolkata'for reports in Indian time. - Put
$matchfirst for speed;$outand$mergesave results to a collection.
Exercise
Write a pipeline that reports, for each product sold in delivered orders, the number of units sold and the revenue, using the item price stored in each order. Hint: the items are inside an array, so try it after reading the next lesson, or use $unwind: '$items' as the second stage. Sort by revenue, highest first, and show the top three.
Show solution
db.orders.aggregate([
{ $match: { status: 'delivered' } },
{ $unwind: '$items' },
{ $group: {
_id: '$items.name',
units: { $sum: '$items.qty' },
revenue: { $sum: { $multiply: ['$items.price', '$items.qty'] } }
} },
{ $sort: { revenue: -1 } },
{ $limit: 3 }
])The top three are the 27-inch Monitor (1 unit, ₹15,999), the Portable SSD 1TB (1 unit, ₹6,999) and the Mechanical Keyboard (1 unit, ₹3,499). $unwind turns each order into one document per item, so $group can add up quantities and line totals per product.
Quick Quiz
1. What does { $group: { _id: '$city', n: { $sum: 1 } } } produce?
Grouping by '$city' creates one group per city, and $sum: 1 counts the documents in each.
2. Why should $match usually be the first stage?
Early filtering is faster and lighter; a leading $match uses indexes like find() does.
3. Which stage works like SQL's HAVING?
A $match after grouping filters the groups by their calculated values.
4. How do you count units in IST months rather than UTC months?
Date expression operators accept a timezone and convert from UTC before extracting parts.
Learn MongoDB with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
