As soon as your app has users, it has questions: how many orders per month? Which products sell best? What is the average rating per genre? You could load every document into Node and compute in JavaScript — and your database would hate you for it.
The aggregation pipeline makes MongoDB do the work. Documents flow through stages, each transforming the stream, like shell pipes. It is the most powerful feature of MongoDB and the one that separates people who use MongoDB from people who understand it.
The mental model
orders ─▶ $match ─▶ $unwind ─▶ $group ─▶ $sort ─▶ $limit ─▶ results
filter flatten summarize order cutEach stage takes documents in, produces documents out. Stages are composable, and where you put a stage matters — a filter early means less work for everything after it.
A sample dataset
db.orders.insertMany([
{
customer: 'aki@example.com',
status: 'paid',
createdAt: ISODate('2026-01-15'),
items: [
{ productId: 'keyboard', price: 80, qty: 1 },
{ productId: 'cable', price: 12, qty: 2 }
]
},
{
customer: 'mina@example.com',
status: 'paid',
createdAt: ISODate('2026-02-03'),
items: [{ productId: 'monitor', price: 240, qty: 1 }]
},
{
customer: 'aki@example.com',
status: 'refunded',
createdAt: ISODate('2026-02-20'),
items: [{ productId: 'cable', price: 12, qty: 1 }]
}
])The stages you will use most
$match — filter
Same syntax as find. Put it first, always, so indexes can be used:
{ $match: { status: 'paid' } }
{ $match: { createdAt: { $gte: ISODate('2026-01-01') } } }$project — shape the output
Include, exclude, rename, or compute fields:
{
$project: {
_id: 0,
customer: 1,
month: { $month: '$createdAt' },
itemCount: { $size: '$items' }
}
}$addFields and $unset — modify the stream
{ $addFields: { year: { $year: '$createdAt' } } }
{ $unset: 'internalNotes' }$sort, $skip, $limit — order and page
{ $sort: { createdAt: -1 } }
{ $skip: 20 }
{ $limit: 10 }$unwind — flatten an array
One document with three items becomes three documents:
{ $unwind: '$items' }This is the stage that makes per-product analysis possible.
$group — summarize
_id is the grouping key; accumulators compute values per group:
{
$group: {
_id: '$customer',
orderCount: { $sum: 1 },
totalSpent: { $sum: '$total' },
firstOrder: { $min: '$createdAt' },
lastOrder: { $max: '$createdAt' }
}
}| Accumulator | Result |
|---|---|
$sum | Total (of a field or 1 for counting) |
$avg | Mean value |
$min / $max | Extremes |
$push | Array of every value in the group |
$addToSet | Array of unique values |
$first / $last | Value from the first/last document (use with $sort) |
$count — a shortcut for totals
{ $count: 'totalOrders' }A complete pipeline
Top products by revenue, with their names attached:
db.orders.aggregate([
// 1. Only paid orders, and only recent ones
{ $match: { status: 'paid', createdAt: { $gte: ISODate('2026-01-01') } } },
// 2. One document per line item
{ $unwind: '$items' },
// 3. Group by product, compute revenue
{
$group: {
_id: '$items.productId',
revenue: { $sum: { $multiply: ['$items.price', '$items.qty'] } },
units: { $sum: '$items.qty' }
}
},
// 4. Biggest first, top 5
{ $sort: { revenue: -1 } },
{ $limit: 5 },
// 5. Join product details
{
$lookup: {
from: 'products',
localField: '_id',
foreignField: '_id',
as: 'product'
}
},
{ $unwind: '$product' },
// 6. Final shape
{
$project: {
_id: 0,
product: '$product.name',
revenue: 1,
units: 1
}
}
])Read it as a sentence: paid orders → per line item → summed per product → sorted → top five → joined to product names → projected. Each stage does one thing.
If you can describe a report as a sequence of “filter, break apart, group, sort, join”, you can write it as an aggregation. Translate the sentence stage by stage.
$lookup — the join
$lookup pulls documents from another collection:
{
$lookup: {
from: 'users', // actual collection name
localField: 'customerId',
foreignField: '_id',
as: 'customer'
}
}The result is an array (a lookup can match many documents), so most joins are followed by $unwind: '$customer'. For complex conditions use the pipeline form of $lookup, which supports let, $match, and limiting returned fields:
{
$lookup: {
from: 'users',
let: { customerId: '$customerId' },
pipeline: [
{ $match: { $expr: { $eq: ['$_id', '$$customerId'] } } },
{ $project: { name: 1, email: 1 } }
],
as: 'customer'
}
}$facet — several reports in one pass
$facet runs multiple sub-pipelines over the same input. The classic use is pagination: page data and total count at the same time.
db.orders.aggregate([
{ $match: { status: 'paid' } },
{
$facet: {
data: [
{ $sort: { createdAt: -1 } },
{ $skip: 0 },
{ $limit: 10 },
{ $project: { customer: 1, createdAt: 1 } }
],
meta: [{ $count: 'total' }]
}
}
])One query, one pass over the matching documents, both answers.
Aggregations from Mongoose
Model.aggregate() takes the same pipeline. Note that results are plain objects, not Mongoose documents — no virtuals, no .save():
const revenueByMonth = await Order.aggregate([
{ $match: { status: 'paid' } },
{
$group: {
_id: { $dateToString: { format: '%Y-%m', date: '$createdAt' } },
revenue: { $sum: '$total' },
orders: { $sum: 1 }
}
},
{ $sort: { _id: 1 } }
])Performance: indexes and explain
An aggregation is only as fast as the stages that can use an index. Two rules:
$matchand$sortfirst. A$matchat the top can use an index; after$unwindit cannot.- Index what you match and sort on.
{ status: 1, createdAt: -1 }supports both the filter and the sort in the pipeline above.
Inspect what MongoDB actually did with explain:
const plan = await Order.aggregate([
{ $match: { status: 'paid' } },
{ $sort: { createdAt: -1 } },
{ $limit: 10 }
]).explain('executionStats')
console.log(plan.stages?.[0]?.$cursor?.executionStats)Look for:
IXSCAN(index used) versusCOLLSCAN(full collection scan — fix the index).totalDocsExaminedclose tonReturned. If it examines 100,000 to return 10, the index is wrong.executionTimeMillis— compare before and after adding an index.
// Cover the pipeline's filter + sort
orderSchema.index({ status: 1, createdAt: -1 })Try it
Insert the sample orders above into mongosh, then run this pipeline. Add $unwind and $group yourself to see revenue per product.
db.orders.aggregate([
{ $match: { status: 'paid' } },
{
$group: {
_id: '$customer',
orders: { $sum: 1 }
}
},
{ $sort: { orders: -1 } }
])Check your understanding
Why should $match come before $group and $unwind whenever possible?
In a $group stage, what does the _id field represent?
Build an order analytics API
You are the backend developer for a small store. Build aggregation endpoints that answer real business questions. Use an orders collection with customer, status, total, createdAt, and items: [{ productId, name, price, qty }].
- Top products: an endpoint returning the five products with the highest revenue. Use
$match,$unwind,$groupwith$multiply,$sort, and$limit. - Monthly revenue: revenue and order count per month for the last 12 months, using
$matchon a date,$dateToStringin the_id, and$sortby month. - Customer leaderboard: per customer: order count, total spent, first and last order dates. Sort by total spent.
- Product detail join: extend the top-products endpoint with a
$lookupto aproductscollection, then$projecta clean shape (product name, revenue, units). - Paginated order list: one endpoint using
$facetthat returns pagedataand ametaobject with the total count in a single query. - Index it: add
{ status: 1, createdAt: -1 }and{ customer: 1 }. Run each pipeline with.explain('executionStats')before and after, and recordtotalDocsExaminedandexecutionTimeMillisin your README. - Cast inputs: accept
?months=and?limit=from the query string, cast to numbers, and clamp them. - Expose each pipeline through Express routes under
/api/v1/analytics/and test with curl.
Summary
- The aggregation pipeline transforms documents stage by stage: filter, shape, flatten, group, sort, join.
$match,$group,$sort,$project,$unwind, and$limitcover most reports.$lookupjoins collections; it returns arrays, so$unwindusually follows.$facetruns multiple sub-pipelines — ideal for paginated data plus a total count.- Mongoose
Model.aggregate()returns plain objects; cast query-string inputs before using them. - Put
$matchand$sortfirst and back them with indexes; verify withexplain('executionStats'). - Aim for
IXSCAN, notCOLLSCAN, andtotalDocsExaminedclose tonReturned.
The database module is complete. Next module: React — building the frontend that consumes all of these APIs.