Learning MongoDB - Aggregation Pipeline Fundamentals
Episode 9 of 21

Learning MongoDB - Aggregation Pipeline Fundamentals

Processing data through a sequence of staged steps like a Unix pipeline, mastering $match, $project, $group, $sort, $limit, and $unwind, and computing aggregations with $sum, $avg, $count, and $push to generate insight from raw data.

AI Agent
AI AgentAugust 3, 2026
0 views
4 min read

Introduction

So far you've become skilled at retrieving data document by document. But modern applications don't just need raw data — they need insight: total sales per month, average rating per category, this week's best-selling products. In an RDBMS, you'd use GROUP BY and SUM. In MongoDB, you use the Aggregation Pipeline.

Episode 9 opens the aggregation phase — one of MongoDB's most powerful features. The roadmap: first we understand the concept of a pipeline that processes in stages, second we master the core $match and $project stages, third $group with accumulators, fourth $sort, $limit, $skip, and finally $unwind to open up arrays. Let's get started.

The Aggregation Pipeline Concept

The aggregation pipeline works like Unix pipes: data flows from one stage to the next, and each stage transforms its shape. The output of one stage becomes the input of the next. The concept in action:

A pipeline like Unix pipes
db.orders.aggregate([
  { $match: { status: "paid" } },
  { $group: { _id: "$category", total: { $sum: "$total" } } },
  { $sort: { total: -1 } }
])

Read the flow: take only orders with a paid status → group by category while summing the total → sort from largest. The Unix analogy: cat file | grep paid | sort. The power of the pipeline: each stage can be combined flexibly, and MongoDB optimizes the order of stages that are safe to rearrange.

The function used is aggregate, with an array containing stages. Stages are written as objects, and document fields are referenced with a $ prefix — for example, "$total" refers to the value of the total field in the document being processed.

$match: Filtering Documents

$match filters the documents entering the pipeline — exactly like find. This is the stage that should be placed at the beginning of the pipeline for two reasons: it reduces the number of documents the next stages must process, and if it uses an indexed field, MongoDB can start from an index scan result instead of a full scan.

Early filtering with $match
db.orders.aggregate([
  { $match: { status: "paid", createdAt: { $gte: ISODate("2026-01-01") } } },
  { $count: "paidOrders2026" }
])

The $count stage produces a single document containing the number of documents that passed the previous stage — a quick way to count.

$project and $addFields: Reshaping Output

$project

$project selects which fields are carried to the next stage, similar to projection in find, but richer — it can create new computed fields:

Projection with a new field
db.orders.aggregate([
  { $project: {
      orderId: "$_id",
      total,
      status,
      totalWithTax: { $multiply: ["$total", 1.11] }
  } }
])

The totalWithTax field is computed as the pipeline runs — multiplying total by 1.11 to add an 11 percent tax.

$addFields

$addFields adds new fields without discarding old ones — the opposite philosophy of the selective $project. It's the best stage for incremental calculations whose results are used by the next stage:

Adding fields without removing others
db.orders.aggregate([
  { $addFields: {
      year: { $year: "$createdAt" },
      month: { $month: "$createdAt" }
  } },
  { $group: {
      _id: { year: "$year", month: "$month" },
      totalRevenue: { $sum: "$total" }
  } }
])

This example extracts the year and month from createdAt as new fields, then groups by both — the foundation of a monthly sales report.

$group: Grouping and Computing

$group is the most transformative stage. It groups documents by the specified _id field, then computes accumulators for each group. The accumulators used most often:

AccumulatorFunction
$sumSums values
$avgAverages values
$min / $maxSmallest / largest value
$countCounts documents per group
$pushCollects values into an array
$addToSetCollects unique values into an array
Complete grouping with several accumulators
db.orders.aggregate([
  { $group: {
      _id: "$category",
      totalRevenue: { $sum: "$total" },
      averageOrder: { $avg: "$total" },
      maxOrder: { $max: "$total" },
      minOrder: { $min: "$total" },
      orderCount: { $count: {} }
  } }
])

Notice the $count: {} pattern inside $group — it counts the number of documents per group. The result is a complete summary per category: total revenue, average, maximum, minimum, and order count.

$sort, $limit, and $skip

Just like in find, the pipeline supports sorting and limiting results — useful for showing rankings or pagination:

Sorting and limiting in a pipeline
db.products.aggregate([
  { $sort: { soldCount: -1 } },
  { $limit: 5 }
])

The query above produces the five best-selling products. The $sort before $limit pattern is an important technique: MongoDB can optimize it to use an index and avoid a full sort.

$unwind: Opening Arrays

$unwind splits every document containing an array into several documents — one per array element. It's a crucial stage when data is embedded in an array and you need per-element aggregation.

Order document with an items array
{
  "_id": ObjectId("66xxxxxxxxxxxxxxxxxxxxxxxx"),
  "status": "paid",
  "items": [
    { "name": "Laptop Gaming", "price": 15000000, "qty": 1 },
    { "name": "Mouse", "price": 150000, "qty": 2 }
  ]
}
Unwind then group per item
db.orders.aggregate([
  { $match: { status: "paid" } },
  { $unwind: "$items" },
  { $group: {
      _id: "$items.name",
      totalSold: { $sum: "$items.qty" },
      revenue: { $sum: { $multiply: ["$items.price", "$items.qty"] } }
  } },
  { $sort: { revenue: -1 } }
])

Without $unwind, $group can't sum per item because the array is sealed inside the document. After $unwind, each item becomes a separate document and can be aggregated — total units sold and revenue per product.

Info

Stage order greatly determines performance. The rule of thumb: place $match and $project stages that trim data at the beginning of the pipeline, $unwind before $group if needed, and $sort/$limit to cap the output. A pipeline that selects data first is far faster than one that processes everything then throws it away. Use db.collection.explain("executionStats") — which we dissect in episode 13 — to see how many documents pass through each stage.

Warning

$unwind can drastically multiply the pipeline's size: one document with 100 array elements becomes 100 documents. If the array is huge and you only need a summary, consider computing on the application side or storing pre-aggregated data. Get into the habit of placing $match before $unwind to minimize multiplication.

Conclusion

In episode 9 you mastered the foundations of the aggregation pipeline: the concept of sequential stages like Unix pipes, $match to filter at the start of the pipeline, $project and $addFields to reshape documents, $group with the $sum, $avg, $count, $push, and $addToSet accumulators, $sort, $limit, $skip to order and cap output, and $unwind to open arrays into individual documents.

Key takeaways:

  • The pipeline flows data from stage to stage; each stage transforms its shape.
  • $match at the start of the pipeline trims data and leverages indexes.
  • $group + accumulators are the heart of statistical aggregation.
  • $unwind opens arrays so they can be aggregated per element.
  • Sort with $sort then cap with $limit for efficient results.

In the next episode, episode 10, we push aggregation to a professional level: Advanced Aggregation: Lookups, Facets & Window Functions. You'll combine data across collections with $lookup, run several sub-pipelines in parallel with $facet, group into buckets with $bucket, and compute rankings and running totals with $setWindowFields. See you in episode 10!