MongoDB Cheatsheet

Aggregation Pipeline

Use this MongoDB reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

The mental model

A pipeline is an ordered array of stages. Each stage takes a stream of documents, transforms it, and hands the result to the next one. Documents in equals documents out only for stages that do not reshape, and any stage may add, drop, or combine documents.

db.orders.aggregate([
  { $match:  { status: "paid" } },       // filter, use the index
  { $group:  { _id: "$region", total: { $sum: "$total" } } },
  { $sort:   { total: -1 } },
  { $limit:  10 }
], { allowDiskUse: true })

Two rules cover most performance problems:

1. $match and $sort first. Only while they sit at the front of the pipeline can they use an index. After a $group or $unwind the data is synthetic and no index exists for it. 2. Shrink early. A $project or $unset that drops big fields before a $group or $lookup cuts memory for every stage after it.

Stage reference

StageDoes
$matchFilter with query syntax. Put it first so it uses an index
$projectReshape. Inclusion or exclusion, plus computed fields
$addFields / $setAdd or overwrite fields, keep everything else. Same stage, two names
$unsetRemove fields. Inverse of $set
$groupCollapse by _id, apply accumulators. _id: null groups everything
$sortOrder. Blocking
$limit · $skipWindow
$count: "n"Replace the stream with one document holding the count
$unwindOne output document per array element
$lookupLeft outer join, into an array field
$graphLookupRecursive self-join, for trees and graphs
$facetRun several sub-pipelines over the same input, in one pass
$bucketGroup into explicit boundaries
$bucketAutoGroup into n roughly equal buckets
$sortByCount$group by an expression, $sum: 1, then sort descending. A shortcut
$replaceRoot / $replaceWithPromote a subdocument to be the whole document
$outWrite results to a collection, replacing it. Must be last
$mergeUpsert results into a collection. Must be last. The materialized-view tool
$sample: { size: n }Random documents
$setWindowFieldsRanks, running totals, moving averages (5.0+)
$densifyFill gaps in a numeric or date sequence with new documents (5.1+)
$fillFill missing values, by carry-forward or linear interpolation (5.3+)
$unionWithConcatenate another collection's documents (4.4+)
$documentsInline literal documents as a source (5.1+)
$geoNearSorted-by-distance source stage. Must be first, needs a geo index
$search · $vectorSearchAtlas Search and vector search. Must be first
$rankFusionReciprocal-rank fusion of several sub-pipelines, for hybrid search (8.1+)
$collStats · $indexStats · $currentOpDiagnostics, and the supported replacement for the deprecated commands

Accumulators for $group, and most of them work in $setWindowFields too:

AccumulatorResult
$sum · $avgTotal, mean. Non-numeric values are ignored, not errors
$min · $maxExtremes, across BSON type order
$pushEvery value as an array, duplicates kept
$addToSetDistinct values as an array, order undefined
$first · $lastValues from the first and last document of the group, so sort first
$count: {}Document count (5.0+). Same as $sum: 1
$stdDevPop · $stdDevSampStandard deviation
$mergeObjectsMerge subdocuments into one
$top · $bottom · $topN · $bottomNBest or worst by a sort key, without a separate $sort (5.2+)

Expression operators

Inside a stage, "$field" reads a field and "$$var" reads a variable. A bare string is a literal, which is why { $set: { a: "$b" } } copies b while { $set: { a: "b" } } stores the letter b.

GroupOperators
Arithmetic$add $subtract $multiply $divide $mod $abs $ceil $floor $round $trunc $pow $sqrt $log
String$concat $toUpper $toLower $substrCP $strLenCP $split $trim $ltrim $rtrim $replaceOne $replaceAll $indexOfCP
Date$dateToString $dateFromString $dateTrunc $dateAdd $dateSubtract $dateDiff $year $month $dayOfMonth $dayOfWeek $hour $week $isoWeek $dateToParts
Conditional$cond $switch $ifNull
Comparison$eq $ne $gt $gte $lt $lte $cmp
Boolean$and $or $not
Array$size $arrayElemAt $first $last $slice $concatArrays $in $indexOfArray $reverseArray $range $zip $arrayToObject $objectToArray
Functional$map $filter $reduce $let $sortArray
Set$setUnion $setIntersection $setDifference $setEquals $anyElementTrue
Type$type $toInt $toLong $toDouble $toDecimal $toString $toBool $toDate $toObjectId $convert $isNumber
Regex$regexMatch $regexFind $regexFindAll
{ $set: {
  full:   { $concat: ["$first", " ", "$last"] },
  slug:   { $toLower: { $replaceAll: { input: "$title", find: " ", replacement: "-" } } },
  initial:{ $substrCP: ["$first", 0, 1] },
  month:  { $dateTrunc: { date: "$placed_at", unit: "month" } },
  due:    { $dateAdd: { startDate: "$placed_at", unit: "day", amount: 30 } },
  age_d:  { $dateDiff: { startDate: "$placed_at", endDate: "$$NOW", unit: "day" } },
  tier:   { $switch: { branches: [
             { case: { $gte: ["$total", 1000] }, then: "gold" },
             { case: { $gte: ["$total", 250]  }, then: "silver" }
           ], default: "bronze" } },
  big:    { $filter: { input: "$items", as: "i", cond: { $gte: ["$$i.qty", 10] } } },
  skus:   { $map: { input: "$items", as: "i", in: "$$i.sku" } },
  units:  { $reduce: { input: "$items", initialValue: 0,
                       in: { $add: ["$$value", "$$this.qty"] } } },
  ref:    { $ifNull: ["$ref", "unknown"] },
  matched:{ $regexMatch: { input: "$email", regex: /@example\.com$/ } }
} }

$$this and $$value are the implicit variables inside $map, $filter, and $reduce. $reduce is the escape hatch for anything the other two cannot express.

Pipeline: monthly revenue by category

Line items live in an array, so unwind, group by the truncated month plus category, then reshape for output.

db.orders.aggregate([
  { $match: {
      status: "paid",
      placed_at: { $gte: ISODate("2026-01-01T00:00:00Z") }
  } },
  { $unwind: "$items" },
  { $group: {
      _id: {
        month:    { $dateTrunc: { date: "$placed_at", unit: "month" } },
        category: "$items.category"
      },
      revenue:   { $sum: { $multiply: ["$items.qty", "$items.price"] } },
      units:     { $sum: "$items.qty" },
      order_ids: { $addToSet: "$_id" }
  } },
  { $set:   { order_count: { $size: "$order_ids" } } },
  { $unset: "order_ids" },
  { $sort:  { "_id.month": 1, revenue: -1 } },
  { $project: {
      _id: 0,
      month:    { $dateToString: { date: "$_id.month", format: "%Y-%m" } },
      category: "$_id.category",
      revenue:  { $round: ["$revenue", 2] },
      units: 1,
      order_count: 1
  } }
])

The $match runs against { status: 1, placed_at: -1 } and is the only stage that can use an index here. $addToSet on $_id then $size counts distinct orders, since $unwind has already multiplied each order by its item count and a plain $sum: 1 would count line items instead.

Pipeline: join and roll up

A correlated $lookup runs a whole sub-pipeline per input document, so the aggregation happens on the foreign side and only one small document comes back.

db.customers.aggregate([
  { $match: { region: "EU", deleted_at: null } },
  { $lookup: {
      from: "orders",
      let: { cid: "$_id" },
      pipeline: [
        { $match: {
            status: "paid",
            $expr: { $eq: ["$customer_id", "$$cid"] }
        } },
        { $group: {
            _id: null,
            total: { $sum: "$total" },
            n:     { $sum: 1 },
            last:  { $max: "$placed_at" }
        } }
      ],
      as: "agg"
  } },
  { $set: { s: { $ifNull: [
      { $first: "$agg" },
      { total: NumberDecimal("0"), n: 0, last: null }
  ] } } },
  { $project: {
      _id: 0, name: 1, email: 1,
      lifetime:    "$s.total",
      order_count: "$s.n",
      last_order:  "$s.last"
  } },
  { $sort:  { lifetime: -1 } },
  { $limit: 25 }
])

The basic non-correlated form is shorter and right when you want the joined documents themselves:

{ $lookup: { from: "orders", localField: "_id",
             foreignField: "customer_id", as: "orders" } }

$lookup always produces an array, empty when nothing matched, which is why $ifNull with $first is the standard unwrap. Index the foreignField or every input document triggers a collection scan on the foreign side. $lookup on a sharded foreign collection needs 5.1+.

Pipeline: top 3 per group

$setWindowFields (5.0+) partitions the stream and computes per-partition values, which is how you do top-N-per-group and running totals without a self-join.

db.sales.aggregate([
  { $match: { year: 2026 } },
  { $setWindowFields: {
      partitionBy: "$region",
      sortBy: { revenue: -1 },
      output: {
        rank:         { $denseRank: {} },
        region_total: { $sum: "$revenue",
                        window: { documents: ["unbounded", "unbounded"] } },
        running:      { $sum: "$revenue",
                        window: { documents: ["unbounded", "current"] } },
        moving_avg:   { $avg: "$revenue",
                        window: { documents: [-2, 0] } }
      }
  } },
  { $match: { rank: { $lte: 3 } } },
  { $set: { share_pct: { $round: [
      { $multiply: [ { $divide: ["$revenue", "$region_total"] }, 100 ] }, 1
  ] } } },
  { $project: { _id: 0, region: 1, rep: 1, revenue: 1, rank: 1,
                share_pct: 1, running: 1 } },
  { $sort: { region: 1, rank: 1 } }
])
Window operatorGives
$rank: {}1, 2, 2, 4. Ties share a rank and leave a gap
$denseRank: {}1, 2, 2, 3. No gaps
$documentNumber: {}1, 2, 3, 4. Ties broken arbitrarily
$shiftA value from n rows away, for period-over-period deltas
$integral · $derivativeArea and rate over a range window
$linearFill · $locfInterpolate or carry the last value forward

window is documents (row offsets) or range (value offsets). Omit it and the default is the whole partition for accumulators. $rank, $denseRank, and $documentNumber require sortBy and reject window.

Variables and limits

VariableIs
$$ROOTThe entire current document. { $replaceWith: "$$ROOT" }, or { $push: "$$ROOT" } in a group
$$NOWPipeline start time as a Date. Identical in every stage and document
$$CLUSTER_TIMEReplica set logical time
$$REMOVEAssign it to drop the field for this document only
$$CURRENTThe current document, rebindable. $$ROOT is fixed
$$this · $$valueLoop variables inside $map, $filter, $reduce
LimitValue
Stages per pipeline1,000
Memory per stage100MB. Since 6.0 stages spill to disk automatically (allowDiskUseByDefault)
$graphLookupHard 100MB, ignores allowDiskUse
Result document16MB, unless you consume a cursor rather than a single document
$out / $mergeMust be the final stage
Sort keys32
db.orders.aggregate(pipeline, { allowDiskUse: true, maxTimeMS: 30000 })

// Materialized daily rollup, rerunnable
db.orders.aggregate([
  { $group: { _id: { $dateTrunc: { date: "$placed_at", unit: "day" } },
              revenue: { $sum: "$total" } } },
  { $merge: { into: "daily_revenue", on: "_id",
              whenMatched: "replace", whenNotMatched: "insert" } }
])

$out replaces the target collection wholesale and drops its indexes. $merge upserts document by document, keeps indexes, and can target another database, so it is the right stage for an incremental rollup you run on a schedule.

Explaining an aggregation

The verb order is inverted compared to find, and explain comes before aggregate.

db.orders.explain("executionStats").aggregate([
  { $match: { status: "paid" } },
  { $group: { _id: "$region", total: { $sum: "$total" } } }
])

Read it from the top. The first entry is a $cursor stage holding an ordinary queryPlanner block, so everything from the index page applies to it: you want IXSCAN there, not COLLSCAN. Stages the optimizer pushed down into that cursor disappear from the stage list, which is the sign your $match, $sort, $limit, or $project reached the query layer. Anything left in the list after the cursor runs in the aggregation layer and cannot use an index.

SymptomCause
$cursor shows COLLSCANThe leading $match has no index, or something precedes it
$sort still listed as a stageThe sort was not served by an index
usedDisk: true on a GROUP or SORTThat stage exceeded 100MB and spilled
$match appears twiceNormal. The optimizer split it to push the indexable half forward