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
| Stage | Does |
|---|---|
$match | Filter with query syntax. Put it first so it uses an index |
$project | Reshape. Inclusion or exclusion, plus computed fields |
$addFields / $set | Add or overwrite fields, keep everything else. Same stage, two names |
$unset | Remove fields. Inverse of $set |
$group | Collapse by _id, apply accumulators. _id: null groups everything |
$sort | Order. Blocking |
$limit · $skip | Window |
$count: "n" | Replace the stream with one document holding the count |
$unwind | One output document per array element |
$lookup | Left outer join, into an array field |
$graphLookup | Recursive self-join, for trees and graphs |
$facet | Run several sub-pipelines over the same input, in one pass |
$bucket | Group into explicit boundaries |
$bucketAuto | Group into n roughly equal buckets |
$sortByCount | $group by an expression, $sum: 1, then sort descending. A shortcut |
$replaceRoot / $replaceWith | Promote a subdocument to be the whole document |
$out | Write results to a collection, replacing it. Must be last |
$merge | Upsert results into a collection. Must be last. The materialized-view tool |
$sample: { size: n } | Random documents |
$setWindowFields | Ranks, running totals, moving averages (5.0+) |
$densify | Fill gaps in a numeric or date sequence with new documents (5.1+) |
$fill | Fill missing values, by carry-forward or linear interpolation (5.3+) |
$unionWith | Concatenate another collection's documents (4.4+) |
$documents | Inline literal documents as a source (5.1+) |
$geoNear | Sorted-by-distance source stage. Must be first, needs a geo index |
$search · $vectorSearch | Atlas Search and vector search. Must be first |
$rankFusion | Reciprocal-rank fusion of several sub-pipelines, for hybrid search (8.1+) |
$collStats · $indexStats · $currentOp | Diagnostics, and the supported replacement for the deprecated commands |
Accumulators for $group, and most of them work in $setWindowFields too:
| Accumulator | Result |
|---|---|
$sum · $avg | Total, mean. Non-numeric values are ignored, not errors |
$min · $max | Extremes, across BSON type order |
$push | Every value as an array, duplicates kept |
$addToSet | Distinct values as an array, order undefined |
$first · $last | Values from the first and last document of the group, so sort first |
$count: {} | Document count (5.0+). Same as $sum: 1 |
$stdDevPop · $stdDevSamp | Standard deviation |
$mergeObjects | Merge subdocuments into one |
$top · $bottom · $topN · $bottomN | Best 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.
| Group | Operators |
|---|---|
| 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 operator | Gives |
|---|---|
$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 |
$shift | A value from n rows away, for period-over-period deltas |
$integral · $derivative | Area and rate over a range window |
$linearFill · $locf | Interpolate 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
| Variable | Is |
|---|---|
$$ROOT | The entire current document. { $replaceWith: "$$ROOT" }, or { $push: "$$ROOT" } in a group |
$$NOW | Pipeline start time as a Date. Identical in every stage and document |
$$CLUSTER_TIME | Replica set logical time |
$$REMOVE | Assign it to drop the field for this document only |
$$CURRENT | The current document, rebindable. $$ROOT is fixed |
$$this · $$value | Loop variables inside $map, $filter, $reduce |
| Limit | Value |
|---|---|
| Stages per pipeline | 1,000 |
| Memory per stage | 100MB. Since 6.0 stages spill to disk automatically (allowDiskUseByDefault) |
$graphLookup | Hard 100MB, ignores allowDiskUse |
| Result document | 16MB, unless you consume a cursor rather than a single document |
$out / $merge | Must be the final stage |
| Sort keys | 32 |
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.
| Symptom | Cause |
|---|---|
$cursor shows COLLSCAN | The leading $match has no index, or something precedes it |
$sort still listed as a stage | The sort was not served by an index |
usedDisk: true on a GROUP or SORT | That stage exceeded 100MB and spilled |
$match appears twice | Normal. The optimizer split it to push the indexable half forward |