$group and Accumulators
$group is the stage in MongoDB’s aggregation pipeline that collapses many documents into fewer summary documents, one per distinct value of a grouping key — the same job GROUP BY does in SQL. Paired with accumulator operators like $sum, $avg, and $push, it is how you compute totals, averages, counts, and per-group lists directly inside the database instead of pulling raw documents into application code and summing them yourself.
Overview / How it works
A MongoDB collection is a set of independent BSON documents with no built-in notion of “rows that belong together.” When you need a summary — total revenue per customer, average order value per status, the list of product IDs each customer bought — you need an operation that walks the document stream, buckets documents by some key, and folds each bucket down into one output document. That is exactly what $group does inside an aggregation pipeline.
Every $group stage needs two things: an _id expression that defines the grouping key, and one or more accumulator fields that define how to combine the values from every document that falls into a given group. The _id can be a single field reference ("$status"), an expression ({ $year: "$createdAt" }), a compound object of several fields, or literally null if you want to collapse the entire input into a single overall summary document.
Internally, $group is a blocking stage: unlike $match or $project, which can pass documents through one at a time, $group must see every document that will land in a given bucket before it can emit that bucket’s final result. The server maintains an in-memory table keyed by the distinct _id values seen so far, updating each accumulator incrementally as documents stream in (for example, a running $sum just keeps adding to a counter — it does not store every value). Because it needs working memory proportional to the number of distinct groups, MongoDB caps a single $group stage at 100MB of RAM by default; if you exceed that on a large, high-cardinality grouping key, the server throws an error unless you add { allowDiskUse: true } to the aggregate() call, which lets it spill intermediate data to temporary files on disk (at the cost of speed).
Because $group completely reshapes each document into { _id: ..., accumulatorField: ... }, any original field you want to keep must be explicitly captured by an accumulator such as $first, $last, or $push — fields not mentioned anywhere in the stage simply disappear from the output.
Syntax
db.collection.aggregate([
{
$group: {
_id: ,
: { : },
: { : }
}
}
]);
- _id — required. The expression used to bucket documents. Use a field path (
"$status"), a computed expression, a compound object ({ year: { $year: "$createdAt" }, status: "$status" }), ornullto group everything into one bucket. - outputField — any name you choose for a summary field in the result; it is not related to any original field name.
- accumulator — one of the operators below, applied across all documents sharing the same
_id.
| Accumulator | What it computes |
|---|---|
$sum |
Total of a numeric expression across the group; $sum: 1 counts documents |
$avg |
Arithmetic mean of a numeric expression |
$min / $max |
Smallest / largest value seen in the group |
$push |
Appends every value (including duplicates) into an array |
$addToSet |
Appends only distinct values into an array (order not guaranteed) |
$first / $last |
Value from the first / last document in the group (meaningful only after a preceding $sort) |
$count |
Number of documents in the group (MongoDB 5.0+ shorthand for $sum: 1) |
$stdDevPop / $stdDevSamp |
Population / sample standard deviation |
$mergeObjects |
Merges documents in the group into a single combined object |
Examples
Example 1: Total spend and order count per customer. Assume a db.orders collection with documents shaped like { customer: "Priya Shah", status: "shipped", total: 249.5, createdAt: ISODate(...) }.
db.orders.aggregate([
{
$group: {
_id: "$customer",
totalSpent: { $sum: "$total" },
orderCount: { $sum: 1 }
}
},
{ $sort: { totalSpent: -1 } }
]);
Output:
[
{ _id: 'Priya Shah', totalSpent: 1284.75, orderCount: 6 },
{ _id: 'Alex Nguyen', totalSpent: 940.2, orderCount: 3 },
{ _id: 'Sam Okafor', totalSpent: 512, orderCount: 2 }
]
Every document is bucketed by its customer value; $sum: "$total" adds up the total field across each customer’s documents, while $sum: 1 simply counts how many documents landed in that bucket. The following $sort stage is a separate stage — $group itself does not guarantee output order.
Example 2: Average, min, and max order value per status.
db.orders.aggregate([
{
$group: {
_id: "$status",
avgTotal: { $avg: "$total" },
minTotal: { $min: "$total" },
maxTotal: { $max: "$total" },
count: { $sum: 1 }
}
}
]);
Output:
[
{ _id: 'shipped', avgTotal: 214.3, minTotal: 42, maxTotal: 610, count: 18 },
{ _id: 'pending', avgTotal: 98.75, minTotal: 20, maxTotal: 300, count: 5 },
{ _id: 'cancelled', avgTotal: 150.5, minTotal: 150.5, maxTotal: 150.5, count: 1 }
]
This is the aggregation-framework equivalent of SELECT status, AVG(total), MIN(total), MAX(total), COUNT(*) FROM orders GROUP BY status in SQL — four independent accumulators all computed in a single pass over each group’s documents.
Example 3: Monthly revenue with a compound key and distinct statuses seen.
db.orders.aggregate([
{ $match: { createdAt: { $gte: new Date("2026-01-01") } } },
{
$group: {
_id: {
year: { $year: "$createdAt" },
month: { $month: "$createdAt" }
},
revenue: { $sum: "$total" },
statusesSeen: { $addToSet: "$status" },
orderIds: { $push: "$_id" }
}
},
{ $sort: { "_id.year": 1, "_id.month": 1 } }
]);
Output:
[
{
_id: { year: 2026, month: 1 },
revenue: 4820.5,
statusesSeen: [ 'shipped', 'pending', 'cancelled' ],
orderIds: [ ObjectId('...'), ObjectId('...'), ObjectId('...') ]
},
{
_id: { year: 2026, month: 2 },
revenue: 3110,
statusesSeen: [ 'shipped', 'pending' ],
orderIds: [ ObjectId('...'), ObjectId('...') ]
}
]
Here the _id is a compound object built from $year and $month date-expression operators, so each output document represents one calendar month. Placing $match before $group lets MongoDB shrink the working set (and use an index on createdAt, if one exists) before the expensive grouping work begins.
How it works step by step
For a pipeline like [{ $match: {...} }, { $group: {...} }], the server: (1) evaluates $match first, using an index (IXSCAN) if one covers the filter, or falling back to a full COLLSCAN if not — check this with explain(); (2) streams the surviving documents into $group, computing the _id expression for each one and looking up (or creating) its bucket in an in-memory hash table; (3) for each accumulator field, folds the current document’s value into that bucket’s running state — $sum adds, $min/$max compare-and-replace, $push appends to an array, and so on; (4) once every input document has been consumed, emits one output document per distinct _id, in no particular guaranteed order — hence chaining a $sort afterward when order matters. Because step (4) can’t happen until all of step (2)/(3) finishes, a $group over a huge, high-cardinality collection is inherently more memory- and time-intensive than a streaming stage like $match or $project.
Common Mistakes
Mistake 1: Forgetting that non-accumulated fields vanish.
// Wrong: expecting the customer's email to still be there
db.orders.aggregate([
{ $group: { _id: "$customer", totalSpent: { $sum: "$total" } } }
]);
// Result documents only have _id and totalSpent - no email field exists.
Fix: explicitly capture any field you need with an accumulator, typically $first after sorting so “first” is meaningful:
db.orders.aggregate([
{ $sort: { createdAt: 1 } },
{
$group: {
_id: "$customer",
totalSpent: { $sum: "$total" },
email: { $first: "$customerEmail" }
}
}
]);
Mistake 2: Unbounded $push on a large group. Pushing every matching document’s data into an array for a group with millions of members can blow past the 16MB BSON document size limit and will always be slower and more memory-hungry than necessary.
// Risky on a huge collection: one customer with 500,000 orders
// produces a single output document with a 500,000-element array.
db.orders.aggregate([
{ $group: { _id: "$customer", allOrders: { $push: "$$ROOT" } } }
]);
Fix: push only the small pieces you actually need (e.g. just _id values), or use $slice in a later stage to cap array size, or reconsider whether you need the raw list at all versus just the aggregated numbers.
Mistake 3: Grouping on a field with inconsistent types. If some documents store total as an integer and others as a string (a common issue with data imported from CSV), grouping and summing on it silently produces wrong or partial totals because $sum ignores non-numeric values instead of erroring. Validate and normalize types on write (with schema validation or in application code) so grouping keys and summed fields are consistently typed.
Best Practices
- Put
$match(and$sort, if it can use an index) before$groupso the expensive grouping step processes as few documents as possible. - Use
$sum: 1or the$countaccumulator purely to count documents per group; reserve$sum: "$field"for actual numeric totals. - Add
{ allowDiskUse: true }as a second argument toaggregate()when grouping over large, high-cardinality datasets that might exceed the 100MB in-memory limit. - Sort before grouping whenever you rely on
$first/$last— their result is undefined without a preceding, deterministic sort. - Prefer
$addToSetover$pushwhen you only care about distinct values, since it avoids storing duplicates. - Check
explain("executionStats")on the pipeline to confirm the pre-$group$matchis usingIXSCANrather than a fullCOLLSCAN.
Practice Exercises
- Given
db.orders, write an aggregation that returns the total revenue and number of orders for the entire collection (hint: use_id: nullto produce one overall document). - Group
db.ordersbystatusand return only statuses whoseorderCountis greater than 10 (hint: you’ll need a$matchstage after$group, since the pre-group$matchcan’t filter on an accumulated value). - Group
db.ordersby customer and collect the distinct set of product categories each customer has ordered from, using an accumulator that avoids duplicate values.
Summary
$groupbuckets documents by an_idexpression and reduces each bucket to one output document using accumulator operators.- Common accumulators include
$sum,$avg,$min,$max,$push,$addToSet,$first, and$last. $groupis a blocking stage that must see every document in a bucket before producing that bucket’s result, capped at 100MB of memory unlessallowDiskUse: trueis set.- Fields not referenced in
_idor an accumulator are dropped from the output entirely. - Filter early with
$matchbefore$groupto shrink the working set and use available indexes.
