The aggregation pipeline: $match, $group, $project, $sort
Pipelines transform documents stage by stage. Build reports with $match, $group, $project, $sort, $limit and accumulators, and learn why stage order matters.
- 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 }
])
| Stage | Purpose |
|---|---|
$match | filter with query syntax |
$project / $set ($addFields) / $unset | reshape, compute fields |
$group | group by _id expression with accumulators |
$sort, $limit, $skip | order and page |
$unwind | one output document per array element |
$count, $sortByCount | shortcuts |
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.
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 } }
])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 }
])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 } }
])db.orders.aggregate([{ $sortByCount: "$payment.method" }])
db.customers.aggregate([{ $match: { tier: "platinum" } }, { $count: "platinumCustomers" }])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
db.orders.aggregate([
{ $group: { _id: "$status", revenue: { $sum: "$total" } } },
{ $match: { _id: "delivered" } }
])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.
db.orders.aggregate([
{ $unwind: "$items" },
{ $group: { _id: "$_id", lines: { $sum: 1 } } }
])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
$matchafter$groupor$unwindwhen it could filter first. - Using
$unwind+$groupto compute something that$sum,$filteror$mapcan 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
$pusharrays 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
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.