The Aggregation Pipeline

MongoDB · Lesson 15 of 28 8 min read Updated

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:

MongoDB Shell
db.orders.aggregate([
  { $group: { _id: '$status', orders: { $sum: 1 }, revenue: { $sum: '$total' } } },
  { $sort: { orders: -1, _id: 1 } }
])
Output
[
  { _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:

StageJobSQL equivalent
$matchKeep documents that match a filterWHERE (or HAVING after a group)
$groupCombine documents into groups and calculate totalsGROUP BY with aggregate functions
$sortOrder documentsORDER BY
$limit / $skipKeep the first n / skip nLIMIT / OFFSET
$project / $setChoose, rename and calculate fieldsThe SELECT list
$lookupBring in documents from another collectionJOIN
$countCount the documents that reach itCOUNT(*)

$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:

MongoDB Shell
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 } }
])
Output
[
  { _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:

AccumulatorResult for each group
$sumTotal of a field, or a count with $sum: 1
$avgAverage
$min / $maxSmallest / largest value
$countNumber of documents ({ $count: {} })
$pushArray of all values
$addToSetArray of distinct values
$first / $lastValue from the first / last document (use after $sort)

Use _id: null to treat the whole collection as one group, which gives grand totals:

MongoDB Shell
db.orders.aggregate([
  { $match: { status: { $ne: 'cancelled' } } },
  { $group: { _id: null, revenue: { $sum: '$total' }, biggest: { $max: '$total' }, smallest: { $min: '$total' } } }
])
Output
[ { _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:

MongoDB Shell
db.orders.aggregate([
  { $match: { status: { $ne: 'cancelled' } } },
  { $group: { _id: '$customerId', spent: { $sum: '$total' }, orders: { $sum: 1 } } },
  { $sort: { spent: -1, _id: 1 } },
  { $limit: 3 }
])
Output
[
  { _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:

MongoDB Shell
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 } }
])
Output
[
  { _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:

MongoDB Shell
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'] }
  } }
])
Output
[
  {
    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 $match as early as possible, so indexes are used and fewer documents flow on.
  • Using old field names after $group. Only _id and your accumulators exist afterwards.
  • Relying on group order. $group output comes in no particular order; always add $sort.

Key points

  • aggregate([...]) runs documents through stages in order; each stage's output feeds the next.
  • $match filters, $group groups by _id and calculates with accumulators, $sort and $limit order and trim.
  • Inside expressions, '$field' means the value of a field; _id: null groups everything together.
  • $project and $set rename, calculate and reshape fields with operators such as $multiply, $round and $cond.
  • Date operators accept timezone: 'Asia/Kolkata' for reports in Indian time.
  • Put $match first for speed; $out and $merge save 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
MongoDB Shell
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?

2. Why should $match usually be the first stage?

3. Which stage works like SQL's HAVING?

4. How do you count units in IST months rather than UTC months?

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