MongoDB Aggregation Pipeline: $match, $group, $lookup Explained
Practice4 minInnovialabs editorial team
In short
An aggregation pipeline is a chain of stages where each one processes the output of the previous; filter with $match as early as possible, aggregate with $group, and run $lookup only on a small result set.
How the aggregation pipeline works
An aggregation pipeline is an array of stages. Documents flow through them in order: each stage takes the stream from the previous one and passes a transformed stream on. This is how you build reports in MongoDB: revenue by month, top customers, sales by product.
The key rule: reduce the data first, compute later. Filtering and sorting go at the start, heavy operations and joins go at the end, on an already small set.
All examples use an orders collection:
{
"_id": "...",
"customerId": "...",
"status": "paid",
"createdAt": "2026-01-15T10:00:00Z",
"total": 420,
"items": [{ "sku": "A-1", "qty": 2, "price": 150 }, { "sku": "B-7", "qty": 1, "price": 120 }]
}
Core stages and their SQL equivalents
| Stage | What it does | SQL equivalent |
|---|---|---|
$match | Filters documents | WHERE (or HAVING after $group) |
$group | Groups and computes sums, counts, averages | GROUP BY + aggregate functions |
$project | Selects, renames and computes fields | the column list in SELECT |
$sort, $limit | Sorting and limiting | ORDER BY, LIMIT |
$unwind | Unrolls an array: one document per element | closest is a JOIN to a child table |
$lookup | Pulls documents from another collection | LEFT OUTER JOIN |
Example 1: top 10 customers for a month
db.orders.aggregate([
{ $match: {
status: "paid",
createdAt: { $gte: ISODate("2026-01-01"), $lt: ISODate("2026-02-01") }
} },
{ $group: { _id: "$customerId", orders: { $sum: 1 }, revenue: { $sum: "$total" } } },
{ $sort: { revenue: -1 } },
{ $limit: 10 },
{ $lookup: { from: "customers", localField: "_id", foreignField: "_id", as: "customer" } },
{ $unwind: "$customer" },
{ $project: { _id: 0, name: "$customer.name", orders: 1, revenue: 1 } }
])
Pay attention to the order:
$matchcomes first and can use an index.$lookupruns after$limit, so it joins only 10 documents instead of every customer of the month.$unwindhere turns thecustomerarray produced by$lookupinto a plain embedded object.
Example 2: sales by product with $unwind
Order lines live in the items array. To compute sales per SKU, unroll the array:
db.orders.aggregate([
{ $match: { status: "paid", createdAt: { $gte: ISODate("2026-01-01") } } },
{ $unwind: "$items" },
{ $group: {
_id: "$items.sku",
qty: { $sum: "$items.qty" },
revenue: { $sum: { $multiply: ["$items.qty", "$items.price"] } }
} },
{ $sort: { revenue: -1 } }
])
$unwind multiplies the number of documents: an order with five lines becomes five documents. That is why it goes after $match, not before.
Indexes that help the pipeline
- Only the leading stages use indexes —
$matchand$sortat the start of the pipeline. After$groupor$unwindthe data is already in memory and collection indexes no longer apply. - For the examples above a compound index
{ status: 1, createdAt: 1 }fits: the equality field first, then the range. - For
$lookup, an index on theforeignFieldin the joined collection matters._idalways has one; for other fields create it, or every join will scan the collection. - Check the plan:
db.orders.explain("executionStats").aggregate([...]). If the first stage showsCOLLSCAN, no index is being used.
Memory limits
- Stages that hold data in memory (
$group,$sort) have a per-stage memory limit. When exceeded, the query either fails or spills to disk, controlled by theallowDiskUseoption. The default behavior depends on the server version, so check the docs for yours. - Every result document is capped at 16 MB. If
$lookuppulls thousands of related documents into one array, you can hit that cap easily. - Spilling to disk avoids the error but is slower. The better fix is to shrink the data with
$matchand$projectbefore the heavy stages.
Common mistakes
$lookupat the start of the pipeline over the whole collection — an expensive join where it is not needed.$unwindbefore filtering: the data volume grows many times over.- Filtering on a computed field when the same filter could run earlier on the source field.
- Heavy reports on the primary during business hours. Use a secondary replica or precompute results with
$mergeinto a separate collection.