MongoDB Mastery
MongoDB Mastery lessons / Module 9: Aggregation Pipeline

The aggregation pipeline: $match, $group, $project, $sort

Level 2: Intermediate25 min

Pipelines transform documents stage by stage. Build reports with $match, $group, $project, $sort, $limit and accumulators, and learn why stage order matters.

This lesson is interactive. Run every example against an in-browser MongoDB, see explain() plans drawn as trees, and take the quiz.
Open the interactive lesson
You will learn to
  • Read and write multi-stage pipelines
  • Group with $sum, $avg, $min, $max, $push, $addToSet, $first
  • Reshape documents with $project, $set and $unset
  • Order stages so indexes and early filtering do the work

conceptThe concept

A pipeline is an array of stages. Each stage receives the stream of documents produced by the previous one.

db.orders.aggregate([
  { $match: { status: "delivered" } },                   // filter (uses indexes when first)
  { $group: { _id: "$shipping.country",                   // group key
              orders: { $sum: 1 }, revenue: { $sum: "$total" } } },
  { $sort: { revenue: -1 } },
  { $limit: 5 }
])
StagePurpose
$matchfilter with query syntax
$project / $set ($addFields) / $unsetreshape, compute fields
$groupgroup by _id expression with accumulators
$sort, $limit, $skiporder and page
$unwindone output document per array element
$count, $sortByCountshortcuts

Field paths are strings starting with $ ("$total"); variables start with $$ ("$$NOW", "$$ROOT"). Expressions are operators such as $add, $multiply, $cond, $dateToString, $size, $filter, $map.

whyWhy it matters

Aggregation is where reporting, analytics, data cleanup and many API responses happen. Running it in the database avoids shipping millions of documents to the application, but only if the pipeline is shaped so the expensive parts touch as little data as possible.

Use caseReal-world scenario

A daily dashboard: revenue by country for delivered orders, top 10 products by units sold, average order value by payment method. Each is a 3 to 5 stage pipeline that runs in milliseconds with the right index and a $match first.

Try itExamples

Run and edit these in the interactive lesson.

Revenue by country
db.orders.aggregate([
  { $match: { status: "delivered" } },
  { $group: { _id: "$shipping.country", orders: { $sum: 1 }, revenue: { $sum: "$total" } } },
  { $project: { country: "$_id", _id: 0, orders: 1, revenue: { $round: ["$revenue", 2] } } },
  { $sort: { revenue: -1 } }
])
Top products by units sold
db.orders.aggregate([
  { $match: { status: { $in: ["delivered", "shipped"] } } },
  { $unwind: "$items" },
  { $group: { _id: "$items.productId", name: { $first: "$items.name" }, units: { $sum: "$items.qty" } } },
  { $sort: { units: -1 } },
  { $limit: 5 }
])
Computed fields and conditional logic
db.orders.aggregate([
  { $match: { createdAt: { $gte: ISODate("2026-09-01") } } },
  { $set: {
      lines: { $size: "$items" },
      band: { $switch: { branches: [
        { case: { $gte: ["$total", 2000] }, then: "large" },
        { case: { $gte: ["$total", 500] }, then: "medium" } ], default: "small" } },
      month: { $dateToString: { format: "%Y-%m", date: "$createdAt" } } } },
  { $group: { _id: "$band", orders: { $sum: 1 }, avgLines: { $avg: "$lines" } } },
  { $sort: { _id: 1 } }
])
$sortByCount and $count
db.orders.aggregate([{ $sortByCount: "$payment.method" }])
db.customers.aggregate([{ $match: { tier: "platinum" } }, { $count: "platinumCustomers" }])
Array expressions without $unwind
db.orders.aggregate([
  { $limit: 3 },
  { $project: {
      orderNo: 1,
      expensive: { $filter: { input: "$items", as: "i", cond: { $gte: ["$$i.price", 500] } } },
      names: { $map: { input: "$items", as: "i", in: { $toUpper: "$$i.name" } } },
      units: { $sum: "$items.qty" } } }
])

internalsUnder the hood

The optimizer rewrites pipelines: it moves $match before $project/$set when the fields allow it, merges $sort + $limit into a top-k sort, and pushes a leading $match/$sort into the query layer so it can use an index. Since 5.x the slot-based execution engine (SBE) runs $group and $lookup faster for eligible pipelines. Each blocking stage ($group, $sort) has a 100 MB memory limit per stage before spilling to disk (allowDiskUse, enabled by default since 6.0).

Use the Step through button on any pipeline example to see the documents after each stage.

Avoid / preferDo this, not that

Avoid
db.orders.aggregate([
  { $group: { _id: "$status", revenue: { $sum: "$total" } } },
  { $match: { _id: "delivered" } }
])
Prefer
db.orders.aggregate([
  { $match: { status: "delivered" } },
  { $group: { _id: "$status", revenue: { $sum: "$total" } } }
])

Filtering after $group means every order is grouped first. Match first: fewer documents flow through, and the $match can use an index.

Avoid
db.orders.aggregate([
  { $unwind: "$items" },
  { $group: { _id: "$_id", lines: { $sum: 1 } } }
])
Prefer
db.orders.aggregate([
  { $project: { lines: { $size: "$items" } } }
])

$unwind multiplies documents only to fold them back together. Array expressions such as $size, $filter and $reduce work on the array in place.

PitfallsCommon mistakes

  • Putting $match after $group or $unwind when it could filter first.
  • Using $unwind + $group to compute something that $sum, $filter or $map can do on the array directly.
  • Forgetting the $ prefix: { $sum: "total" } sums the string literal, giving 0.
  • Expecting a 16 MB document limit not to apply: each output document (and $push arrays in $group) must still fit in 16 MB.

LimitsWhen not to use it

Heavy analytical pipelines over the whole dataset should not run on the primary during peak traffic. Send them to a secondary or an analytics node, or to a warehouse. Blocking stages such as $group and $sort use up to 100 MB each before spilling to disk.

SecuritySecurity notes

Never accept a raw pipeline from a client. A pipeline can $lookup into other collections and $out or $merge over existing data. Build pipelines on the server from validated parameters.

ChallengeTry it yourself

For delivered orders, show the order count and revenue per payment method, revenue rounded to 2 decimals, highest revenue first.

Show one solution
One solution
db.orders.aggregate([
  { $match: { status: "delivered" } },
  { $group: { _id: "$payment.method", orders: { $sum: 1 }, revenue: { $sum: "$total" } } },
  { $project: { orders: 1, revenue: { $round: ["$revenue", 2] } } },
  { $sort: { revenue: -1 } }
])

InterviewQuestions you should be able to answer

How do you make an aggregation pipeline fast?

Start with $match (and $sort) on indexed fields so the pipeline begins from an index scan; project away unneeded fields early only if it reduces a later blocking stage; use $sort + $limit together for top-k; avoid $unwind where array expressions work; check explain() for COLLSCAN and the first stage; run heavy analytics on a secondary or an analytics node.

What is the difference between $project and $set?

$set (alias $addFields) adds or overwrites fields and keeps everything else. $project outputs only the fields you list (or excludes the ones you set to 0). Use $set to compute, $project to shape the final output.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.