
MongoDB Aggregation Pipeline for Beginners: $match, $group, and $project
At some point, find() stops being enough. Your product manager wants revenue per month, your dashboard needs the ten best-selling products, and support wants to know which customers placed more than five orders last quarter. You could pull every document into your application and crunch the numbers in a loop, but that means shipping megabytes of data across the network just to compute a handful of totals.
The aggregation pipeline lets MongoDB do that work where the data lives. You describe a sequence of steps (filter these documents, group them by that field, compute this total, reshape the output), and the server streams documents through each step and hands you only the result. It sounds intimidating because of the syntax, but most real pipelines are built from a small set of stages, and three of them do the heavy lifting.
This guide covers how pipelines work, $match, $group, and $project in depth, the supporting stages you'll use alongside them ($sort, $limit, $unwind, $set), a complete worked example, running pipelines from code, and the habits that keep them fast.
How a Pipeline Works
A pipeline is an array of stages. Each stage takes a stream of documents in, transforms it, and passes a stream of documents out to the next stage. The output of the last stage is your result.
db.orders.aggregate([
{ $match: { status: "completed" } }, // 1. keep only completed orders
{ $group: { _id: "$customerId", spent: { $sum: "$total" } } }, // 2. total per customer
{ $sort: { spent: -1 } }, // 3. biggest spenders first
{ $limit: 5 }, // 4. top five
]);
Read it top to bottom like a recipe. The documents coming out of $group look nothing like the orders going in: they have an _id (the customer) and a spent total. Each stage only sees what the previous stage produced.
That's the key mental model. If a later stage can't find a field, it's usually because an earlier stage removed or renamed it.
Sample Data
The examples use an orders collection. Load it in mongosh to follow along:
db.orders.insertMany([
{
_id: 1,
customerId: "ada",
status: "completed",
total: 120,
region: "EU",
createdAt: ISODate("2026-07-03T10:00:00Z"),
items: [
{ sku: "MUG-01", qty: 2, price: 12 },
{ sku: "LAMP-04", qty: 1, price: 96 },
],
},
{
_id: 2,
customerId: "grace",
status: "completed",
total: 45,
region: "US",
createdAt: ISODate("2026-07-15T14:30:00Z"),
items: [
{ sku: "PEN-10", qty: 5, price: 1 },
{ sku: "PAD-02", qty: 10, price: 4 },
],
},
{
_id: 3,
customerId: "ada",
status: "completed",
total: 24,
region: "EU",
createdAt: ISODate("2026-08-02T09:15:00Z"),
items: [{ sku: "MUG-01", qty: 2, price: 12 }],
},
{
_id: 4,
customerId: "linus",
status: "cancelled",
total: 96,
region: "EU",
createdAt: ISODate("2026-08-10T16:45:00Z"),
items: [{ sku: "LAMP-04", qty: 1, price: 96 }],
},
{
_id: 5,
customerId: "grace",
status: "completed",
total: 108,
region: "US",
createdAt: ISODate("2026-08-21T11:20:00Z"),
items: [
{ sku: "LAMP-04", qty: 1, price: 96 },
{ sku: "MUG-01", qty: 1, price: 12 },
],
},
]);
$match: Filter Early
$match filters documents using the same query language as find():
db.orders.aggregate([{ $match: { status: "completed", region: "EU" } }]);
// orders 1 and 3
All the familiar operators work: $gt, $in, $regex, $elemMatch, and the rest (the guide to query operators covers them). Date ranges are especially common:
{ $match: { createdAt: { $gte: ISODate("2026-08-01"), $lt: ISODate("2026-09-01") } } }
Put $match as early as possible. A $match at the start of a pipeline can use indexes, exactly like a find() query. Every document it removes is a document later stages don't have to process. MongoDB's optimizer will move some $match stages earlier automatically, but don't rely on it; write the pipeline in the efficient order yourself.
$match can also appear later in a pipeline to filter on computed values. At that point it can't use indexes, but that's fine because it's working on a much smaller stream. For comparisons between two fields in the same document, use $expr:
{
$match: {
$expr: {
$gt: ["$total", 100];
}
}
}
$group: Summarize Documents
$group is where aggregation earns its name. It collects documents that share a key and computes values across each group.
Every $group has an _id, which is the grouping key, and any number of accumulator fields:
db.orders.aggregate([
{ $match: { status: "completed" } },
{
$group: {
_id: "$customerId",
orders: { $sum: 1 },
spent: { $sum: "$total" },
avgOrder: { $avg: "$total" },
lastOrder: { $max: "$createdAt" },
},
},
]);
[
{
_id: "ada",
orders: 2,
spent: 144,
avgOrder: 72,
lastOrder: ISODate("2026-08-02T09:15:00Z"),
},
{
_id: "grace",
orders: 2,
spent: 153,
avgOrder: 76.5,
lastOrder: ISODate("2026-08-21T11:20:00Z"),
},
];
(Linus's only order was cancelled, so the $match removed him before grouping.) The output order of $group isn't guaranteed. Add a $sort if you need one.
Note the $ in "$customerId" and "$total". Inside expressions, a string starting with $ means "the value of this field". Without it, "customerId" would be the literal text, and every document would land in one group called "customerId".
Common Accumulators
| Accumulator | Returns |
|---|---|
$sum | Sum of values; $sum: 1 counts documents |
$avg | Average of numeric values |
$min / $max | Smallest / largest value |
$first / $last | Value from the first / last document in the group (sort first!) |
$push | Array of all values |
$addToSet | Array of unique values |
$count | Number of documents (MongoDB 5.0+; { $count: {} }) |
Recent versions also include $top, $bottom, $topN, $bottomN, $firstN, and $lastN, which pick values according to their own sort order without a separate $sort stage:
{
$group: {
_id: "$region",
biggestOrders: { $topN: { n: 2, sortBy: { total: -1 }, output: { id: "$_id", total: "$total" } } },
},
}
Grouping Everything
Use _id: null to compute totals across the whole input:
db.orders.aggregate([
{ $match: { status: "completed" } },
{ $group: { _id: null, revenue: { $sum: "$total" }, orders: { $sum: 1 } } },
]);
// [ { _id: null, revenue: 297, orders: 4 } ]
Grouping by Multiple Fields
The _id can be a document, which gives you a compound key:
db.orders.aggregate([
{ $match: { status: "completed" } },
{
$group: {
_id: {
region: "$region",
month: { $dateTrunc: { date: "$createdAt", unit: "month" } },
},
revenue: { $sum: "$total" },
},
},
{ $sort: { "_id.month": 1, "_id.region": 1 } },
]);
[
{
_id: { region: "EU", month: ISODate("2026-07-01T00:00:00Z") },
revenue: 120,
},
{
_id: { region: "US", month: ISODate("2026-07-01T00:00:00Z") },
revenue: 45,
},
{
_id: { region: "EU", month: ISODate("2026-08-01T00:00:00Z") },
revenue: 24,
},
{
_id: { region: "US", month: ISODate("2026-08-01T00:00:00Z") },
revenue: 108,
},
];
$dateTrunc rounds a date down to the start of its month (or day, week, hour). It accepts a timezone option, which matters: "August" in UTC and "August" in Los Angeles aren't the same range.
$project: Shape the Output
$project controls which fields leave a stage and lets you compute new ones. The basics mirror find() projection:
{ $project: { _id: 0, customerId: 1, total: 1 } }
Where it gets interesting is computed fields:
db.orders.aggregate([
{ $match: { status: "completed" } },
{
$project: {
_id: 0,
orderId: "$_id",
customer: { $toUpper: "$customerId" },
itemCount: { $size: "$items" },
totalWithTax: { $round: [{ $multiply: ["$total", 1.2] }, 2] },
size: { $cond: [{ $gte: ["$total", 100] }, "large", "small"] },
month: { $dateToString: { format: "%Y-%m", date: "$createdAt" } },
},
},
]);
[
{
orderId: 1,
customer: "ADA",
itemCount: 2,
totalWithTax: 144,
size: "large",
month: "2026-07",
},
{
orderId: 2,
customer: "GRACE",
itemCount: 2,
totalWithTax: 54,
size: "small",
month: "2026-07",
},
// ...
];
Two rules trip up beginners:
$projectdrops everything you don't mention. If you include fields (with1or an expression), any field you don't list disappears, except_id, which you must exclude explicitly.- You can't mix inclusion and exclusion, apart from excluding
_id.{ name: 1, password: 0 }is an error.
$set and $unset: Lighter Alternatives
When you want to add or change a few fields and keep everything else, $set (an alias of $addFields) is easier than listing every field in $project:
{
$set: {
itemCount: {
$size: "$items";
}
}
}
And $unset removes fields without touching the rest:
{
$unset: ["items", "internalNotes"];
}
A good habit: use $set and $unset in the middle of pipelines, and $project at the end to define the exact output shape.
Supporting Stages
$sort, $limit, and $skip
$sort orders documents, $limit keeps the first N, and $skip discards the first N. A $sort followed by $limit is optimized so the server only tracks the top N documents rather than sorting everything in memory. A $sort at the start of a pipeline can use an index.
Blocking stages like $sort and $group have a 100 MB memory limit per stage. In recent versions, pipelines spill to disk automatically when needed (allowDiskUse defaults to true from MongoDB 6.0), but a pipeline that needs to spill is usually a sign that it should filter more before sorting.
$unwind
$unwind turns one document with an array into one document per element. It's how you group by values inside arrays, such as best-selling products:
db.orders.aggregate([
{ $match: { status: "completed" } },
{ $unwind: "$items" },
{
$group: {
_id: "$items.sku",
unitsSold: { $sum: "$items.qty" },
revenue: { $sum: { $multiply: ["$items.qty", "$items.price"] } },
},
},
{ $sort: { unitsSold: -1 } },
]);
[
{ _id: "PAD-02", unitsSold: 10, revenue: 40 },
{ _id: "PEN-10", unitsSold: 5, revenue: 5 },
{ _id: "MUG-01", unitsSold: 5, revenue: 60 },
{ _id: "LAMP-04", unitsSold: 2, revenue: 192 },
];
Each order with two items becomes two documents after $unwind, so the $group can add up quantities per SKU. $unwind multiplies your document count, so always $match first. And note that PEN-10 and MUG-01 tie on unitsSold, so their relative order isn't guaranteed; add a tiebreaker like { unitsSold: -1, _id: 1 } when order must be stable.
$count
$count outputs a single document with the number of documents that reached it:
db.orders.aggregate([
{ $match: { status: "completed", total: { $gt: 50 } } },
{ $count: "bigOrders" },
]);
// [ { bigOrders: 2 } ]
A Complete Example: Monthly Sales Report
Putting the stages together, here's a report of completed revenue per month, with order count, average order value, number of distinct customers, and the largest single order:
db.orders.aggregate([
// 1. only completed orders in the reporting window (can use an index on status + createdAt)
{
$match: {
status: "completed",
createdAt: { $gte: ISODate("2026-07-01"), $lt: ISODate("2026-09-01") },
},
},
// 2. one summary document per month
{
$group: {
_id: { $dateTrunc: { date: "$createdAt", unit: "month" } },
orders: { $sum: 1 },
revenue: { $sum: "$total" },
avgOrder: { $avg: "$total" },
customers: { $addToSet: "$customerId" },
biggestOrder: { $max: "$total" },
},
},
// 3. final shape
{
$project: {
_id: 0,
month: { $dateToString: { format: "%Y-%m", date: "$_id" } },
orders: "$orders",
revenue: "$revenue",
avgOrder: { $round: ["$avgOrder", 2] },
uniqueCustomers: { $size: "$customers" },
biggestOrder: "$biggestOrder",
},
},
// 4. chronological order
{ $sort: { month: 1 } },
]);
[
{
month: "2026-07",
orders: 2,
revenue: 165,
avgOrder: 82.5,
uniqueCustomers: 2,
biggestOrder: 120,
},
{
month: "2026-08",
orders: 2,
revenue: 132,
avgOrder: 66,
uniqueCustomers: 2,
biggestOrder: 108,
},
];
Notice how each stage has one job, and each works on the output of the previous one. The $project refers to $avgOrder and $customers, fields that only exist because $group created them. Writing orders: "$orders" instead of orders: 1 is equivalent here, but it makes every output field an explicit expression, which reads nicely in a final reporting stage.
Want the best-selling SKU per month too? That needs line items, so it's a separate chain of stages: unwind the items, sum units per month and SKU, then keep the top SKU for each month with $top:
db.orders.aggregate([
{
$match: {
status: "completed",
createdAt: { $gte: ISODate("2026-07-01"), $lt: ISODate("2026-09-01") },
},
},
{ $unwind: "$items" },
{
$group: {
_id: {
month: { $dateTrunc: { date: "$createdAt", unit: "month" } },
sku: "$items.sku",
},
units: { $sum: "$items.qty" },
},
},
{
$group: {
_id: "$_id.month",
topSku: {
$top: {
sortBy: { units: -1 },
output: { sku: "$_id.sku", units: "$units" },
},
},
},
},
{ $sort: { _id: 1 } },
]);
[
{
_id: ISODate("2026-07-01T00:00:00Z"),
topSku: { sku: "PAD-02", units: 10 },
},
{ _id: ISODate("2026-08-01T00:00:00Z"), topSku: { sku: "MUG-01", units: 3 } },
];
In August, MUG-01 (3 units) beats LAMP-04 (1 unit), because Linus's cancelled lamp order was filtered out by the first $match. Grouping twice, first by a compound key and then by part of it, is a pattern you'll use constantly.
When you need both summaries from the same input in one round trip, $facet runs several sub-pipelines side by side; it's covered in the guide to using $facet for multi-faceted results. Whatever you build, do it one stage at a time: run the first stage, check the output, add the next, check again.
Running Pipelines from Code
The pipeline is just an array, so it moves between the shell and drivers unchanged. In Node.js:
const topCustomers = await db
.collection("orders")
.aggregate([
{ $match: { status: "completed" } },
{ $group: { _id: "$customerId", spent: { $sum: "$total" } } },
{ $sort: { spent: -1 } },
{ $limit: 10 },
])
.toArray();
In PyMongo:
pipeline = [
{"$match": {"status": "completed"}},
{"$group": {"_id": "$region", "revenue": {"$sum": "$total"}}},
{"$sort": {"revenue": -1}},
]
for row in db.orders.aggregate(pipeline):
print(row["_id"], row["revenue"])
# US 153
# EU 144
Because pipelines are data, you can build them conditionally, adding a $match only when a filter is present, for example. Just never insert raw user input as an operator or field path; validate it first.
MongoDB Compass has an aggregation pipeline builder that shows sample output after every stage, which is a great way to learn. You can export the finished pipeline to your driver's language.
Best Practices
Filter first, always. Start with a $match that uses an index. It's the single biggest performance lever you have.
Project late, but trim early when documents are large. If documents carry big fields you don't need (long descriptions, embedded history), drop them with $unset or $project early so later stages move less data.
Sort before $first and $last. Their results depend on document order, which is only defined after a $sort. Or use $top and $bottom, which carry their own sort.
Check the plan. Run db.orders.explain("executionStats").aggregate([...]) and confirm the first stage uses an IXSCAN, not a COLLSCAN.
Build incrementally. Add one stage at a time and look at the output. Most pipeline bugs are a field that was renamed or removed two stages earlier.
Conclusion
The aggregation pipeline turns MongoDB from a document store into an analytics engine. $match filters with the full query language and uses indexes when it runs first. $group collapses documents into summaries with accumulators like $sum, $avg, and $push. $project, $set, and $unset shape the output into exactly what your application needs. Add $sort, $limit, and $unwind, and you can answer most reporting questions in a single round trip.
Pick one place in your code that fetches documents and then loops over them to compute a total or a count. Rewrite it as a $match plus $group pipeline, compare the response time and payload size, and you'll see why aggregation is worth learning.


