Skip to content
Innovialabs

MongoDB Aggregation Pipeline: $match, $group, $lookup Explained

Practice4 min

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

StageWhat it doesSQL equivalent
$matchFilters documentsWHERE (or HAVING after $group)
$groupGroups and computes sums, counts, averagesGROUP BY + aggregate functions
$projectSelects, renames and computes fieldsthe column list in SELECT
$sort, $limitSorting and limitingORDER BY, LIMIT
$unwindUnrolls an array: one document per elementclosest is a JOIN to a child table
$lookupPulls documents from another collectionLEFT 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:

  • $match comes first and can use an index.
  • $lookup runs after $limit, so it joins only 10 documents instead of every customer of the month.
  • $unwind here turns the customer array produced by $lookup into 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 — $match and $sort at the start of the pipeline. After $group or $unwind the 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 the foreignField in the joined collection matters. _id always 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 shows COLLSCAN, 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 the allowDiskUse option. The default behavior depends on the server version, so check the docs for yours.
  • Every result document is capped at 16 MB. If $lookup pulls 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 $match and $project before the heavy stages.

Common mistakes

  • $lookup at the start of the pipeline over the whole collection — an expensive join where it is not needed.
  • $unwind before 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 $merge into a separate collection.

FAQ

find is for fetching documents as they are. When you need to group, sum, unroll arrays or join collections, use the aggregation pipeline so the computation happens on the server rather than in application code.

We can help with this

If you'd rather get the result than figure it all out yourself — this is what we do every day.

Discuss a project

Read next

Have an idea or a task? Tell us about it

We'll reply within one business day and propose a solution. The consultation is free.