Skip to content
Navigation

Type at least two characters. Search covers page titles, headings, tags and database names.

↑ ↓ to navigateEnter to openEsc to close0 pages
MongoDBintermediate

MongoDB Aggregation Pipeline

Writing pipelines that use indexes, the stages that force everything into memory, and how to read their explain output.

2 min readIntermediateUpdated Edit this page

The aggregation pipeline transforms documents through a sequence of stages. Its performance is decided almost entirely by whether the first stages can use an index.

db.orders.aggregate([
  // 1. Filter first, on an indexed field.
  { $match: { status: "paid", createdAt: { $gte: ISODate("2026-07-01") } } },
  // 2. Drop fields you do not need, before they are carried through.
  { $project: { customerId: 1, totalCents: 1, createdAt: 1 } },
  { $group: { _id: "$customerId", revenue: { $sum: "$totalCents" }, orders: { $sum: 1 } } },
  { $sort: { revenue: -1 } },
  { $limit: 20 }
]);

Order the stages deliberately

$match first, always. Only a $match at the start of the pipeline can use an index. Once a $group or $unwind has run, later $match stages filter in memory.

$project or $unset early to stop carrying fields through every subsequent stage.

$sort before $group only if the sort can use an index; otherwise sort at the end on the smaller result.

$limit as early as correctness allows. MongoDB can push a $sort plus $limit into a top-k selection rather than sorting everything.

Stages that consume memory

$lookup deserves specific caution: it runs a query against the foreign collection for each input document unless the join can use an index on the foreign key. Always index the foreign field, and treat a $lookup on a hot path as a modelling question — see Document Modeling.

$unwind on a large array multiplies the document count for every subsequent stage. Filter before unwinding, not after.

Reading explain

db.orders.explain("executionStats").aggregate(pipeline);

Look for:

  • IXSCAN in the first stage. A COLLSCAN means the leading $match is not indexed.
  • totalDocsExamined versus nReturned. A large gap means the index is not selective.
  • hasSortStage or SORT in the plan — an in-memory sort that an index could have avoided.
  • spilledRecords or disk usage in the execution stats.

Useful stages

// $facet: several aggregations over the same input in one pass.
db.orders.aggregate([
  { $match: { createdAt: { $gte: ISODate("2026-07-01") } } },
  { $facet: {
      byStatus:  [ { $group: { _id: "$status", n: { $sum: 1 } } } ],
      revenue:   [ { $group: { _id: null, total: { $sum: "$totalCents" } } } ],
      topOrders: [ { $sort: { totalCents: -1 } }, { $limit: 5 } ]
  }}
]);
 
// $merge: write results into a collection, for scheduled rollups.
db.orders.aggregate([
  { $match: { createdAt: { $gte: since } } },
  { $group: { _id: { d: { $dateTrunc: { date: "$createdAt", unit: "day" } } },
              revenue: { $sum: "$totalCents" } } },
  { $merge: { into: "daily_revenue", on: "_id", whenMatched: "replace" } }
]);

$merge is how you build incremental rollups: run it on a schedule over a recent window, and the expensive aggregation stops being on the read path.