The $group Stage in MongoDB: Accumulators, Grouping Keys, and Performance
$group is MongoDB's GROUP BY. It collapses a stream of documents into one document per distinct key and computes aggregates -- counts, sums, arrays of values, first/last per group -- along the way. It is also one of the easiest stages to write inefficiently, because it blocks the pipeline and ignores most indexes.
This guide covers the syntax that trips people up, the full accumulator toolbox, the top-N-per-group problem, and what actually determines $group performance. For the pipeline model itself, start with our aggregation pipeline guide.
The shape of a $group stage
{
$group: {
_id: <grouping key expression>, // required
field1: { <accumulator>: <expression> },
field2: { <accumulator>: <expression> }
}
}Two rules that confuse everyone at first:
_idis mandatory and it is the grouping key, nothing to do with the documents'_idfield. Forgetting it is an error.- Field references need
$prefixes inside expressions:{ $sum: "$amount" }sums theamountfield;{ $sum: "amount" }tries to sum the literal string"amount"and silently produces0. No error, just wrong numbers. This is the single most common$groupbug.
Grouping keys
Single field:
{ $group: { _id: "$status", count: { $sum: 1 } } }Everything in one group -- use null (or any constant):
{ $group: { _id: null, total: { $sum: "$amount" }, n: { $sum: 1 } } }Multiple fields -- the key is a document:
{ $group: {
_id: { country: "$country", status: "$status" },
count: { $sum: 1 }
}}Downstream stages reference the parts as _id.country and _id.status. A $project after the group can flatten them back to top-level fields.
Computed keys -- any expression works. Grouping by month:
{ $group: {
_id: { $dateTrunc: { date: "$createdAt", unit: "month" } },
revenue: { $sum: "$amount" }
}}($dateTrunc and its timezone handling are covered in the date operators guide.)
One subtlety: grouping keys are compared by value including type. 42 (int) and 42.0 (double) group together because BSON numeric comparison is type-bridging, but "42" (string) is a separate group. If a field holds mixed types, you may get more groups than expected -- see BSON data types for the type-audit query.
The accumulator toolbox
Counting and arithmetic:
| Accumulator | What it does |
|---|---|
$sum | Sum; { $sum: 1 } is the idiomatic count. Ignores non-numeric values |
$count | Shorthand for { $sum: 1 } (5.0+) |
$avg | Mean, ignoring non-numeric values |
$min / $max | Smallest / largest value by BSON comparison order |
$stdDevPop / $stdDevSamp | Population / sample standard deviation |
Collecting values into arrays:
| Accumulator | What it does |
|---|---|
$push | Every value, duplicates included, in document order |
$addToSet | Distinct values, order not guaranteed |
$push can collect whole sub-documents: { $push: { item: "$item", qty: "$qty" } }. Watch the memory: pushing large documents into groups with millions of members is how you hit the 100MB stage limit.
Positional (order-dependent):
| Accumulator | What it does |
|---|---|
$first / $last | Value from the first/last document in the group |
$firstN / $lastN | Array of the first/last N values (5.2+) |
$top / $bottom | Output from the highest/lowest-ranked document by a sortBy you specify (5.2+) |
$topN / $bottomN | Same, but N of them (5.2+) |
$first and $last are only meaningful if the input order is defined -- put a $sort before the $group. Without one, you get some document, nondeterministically.
There are also $mergeObjects (fold documents together, later keys win) and $accumulator (custom JavaScript -- slow, last resort).
Top-N per group
"Latest order per customer" / "top 3 products per category" is the classic problem. Three approaches:
1. $sort + $first (works on every version):
db.orders.aggregate([
{ $sort: { customerId: 1, createdAt: -1 } },
{ $group: {
_id: "$customerId",
latestOrder: { $first: "$$ROOT" }
}}
])$$ROOT grabs the whole document. This pattern has a hidden superpower: with an index on { customerId: 1, createdAt: -1 }, the optimizer can answer it with a DISTINCT_SCAN -- a loose index scan that jumps between distinct key values instead of reading every document. Check explain() for DISTINCT_SCAN; it is the difference between milliseconds and minutes on large collections.
2. $topN (5.2+, no separate $sort needed):
{ $group: {
_id: "$category",
top3: { $topN: {
n: 3,
sortBy: { revenue: -1 },
output: { name: "$name", revenue: "$revenue" }
}}
}}Cleaner to read, and the server only retains N items per group instead of accumulating everything. Note: as of current versions the $sort+$first form is the one eligible for the DISTINCT_SCAN optimization, so for "exactly one per group" on an indexed sort, it is often still faster. Measure with explain() rather than assuming.
3. $push then $slice -- collect everything, then trim in a $project. Works, but accumulates entire groups in memory first. Avoid it when groups are large; prefer $topN.
Performance: what actually matters
$group is a blocking stage. It cannot emit a single result until it has seen all input, because the last input document might belong to any group. Everything upstream must complete first.
It uses indexes for its input, not for grouping. A $match or $sort before the $group can use indexes; the grouping itself is a hash aggregation over whatever flows in. The two exceptions worth knowing: the DISTINCT_SCAN pattern above, and the fact that a covered pipeline ($match + $project of indexed fields only) feeds the group from the index without touching documents.
The 100MB limit. Each stage gets 100MB of RAM; a $group holding many groups (or fat $push arrays) exceeds it and errors. { allowDiskUse: true } spills to disk -- it works, but it is an order of magnitude slower and usually a sign you should reduce data earlier:
$matchfirst, always. Group 100k matching documents, not 50M.$project/$unsetaway big fields you do not aggregate before the group.- Replace
$push-everything with$topN/$firstNwhen you only need a few per group.
Pre-aggregate if you run it constantly. If the same $group runs on every dashboard load, consider maintaining a summary collection with $merge on a schedule, or incrementing counters at write time with $inc.
Common mistakes, quickly
{ $sum: "amount" }instead of{ $sum: "$amount" }-- returns 0, no error.$firstwithout a preceding$sort-- nondeterministic result.- Grouping on an array field -- the whole array is the key (exact-array match), which is rarely what you want.
$unwindfirst; see arrays and $unwind. - Expecting
$addToSetoutput to be ordered -- it is not, sort it afterward with$sortArray. nulland missing fields group together under_id: nullalong with the all-documents constant-key pattern -- if you group by a sometimes-missing field,$matchit first or default it with$ifNull.
SQL to MongoDB cheat sheet
| SQL | MongoDB |
|---|---|
GROUP BY status | _id: "$status" |
COUNT(*) | { $sum: 1 } |
COUNT(DISTINCT x) | group by x, then $count (or $addToSet + $size) |
HAVING total > 100 | $match after the $group |
SUM(amount) FILTER (WHERE ...) | { $sum: { $cond: [<cond>, "$amount", 0] } } |
Mako connects to MongoDB with AI-powered autocomplete that helps you build aggregation pipelines. Try it free at mako.ai.
Skip the terminal. Use Mako.
Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.