The $group Stage in MongoDB: Accumulators, Grouping Keys, and Performance

7 min readMongoDB

$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:

  1. _id is mandatory and it is the grouping key, nothing to do with the documents' _id field. Forgetting it is an error.
  2. Field references need $ prefixes inside expressions: { $sum: "$amount" } sums the amount field; { $sum: "amount" } tries to sum the literal string "amount" and silently produces 0. No error, just wrong numbers. This is the single most common $group bug.

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:

AccumulatorWhat it does
$sumSum; { $sum: 1 } is the idiomatic count. Ignores non-numeric values
$countShorthand for { $sum: 1 } (5.0+)
$avgMean, ignoring non-numeric values
$min / $maxSmallest / largest value by BSON comparison order
$stdDevPop / $stdDevSampPopulation / sample standard deviation

Collecting values into arrays:

AccumulatorWhat it does
$pushEvery value, duplicates included, in document order
$addToSetDistinct 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):

AccumulatorWhat it does
$first / $lastValue from the first/last document in the group
$firstN / $lastNArray of the first/last N values (5.2+)
$top / $bottomOutput from the highest/lowest-ranked document by a sortBy you specify (5.2+)
$topN / $bottomNSame, 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:

  • $match first, always. Group 100k matching documents, not 50M.
  • $project/$unset away big fields you do not aggregate before the group.
  • Replace $push-everything with $topN/$firstN when 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.
  • $first without 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. $unwind first; see arrays and $unwind.
  • Expecting $addToSet output to be ordered -- it is not, sort it afterward with $sortArray.
  • null and missing fields group together under _id: null along with the all-documents constant-key pattern -- if you group by a sometimes-missing field, $match it first or default it with $ifNull.

SQL to MongoDB cheat sheet

SQLMongoDB
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.

Mako — open source

Skip the terminal. Use Mako.

Connect your database, write queries with AI assistance, and import/export data in clicks. Free to start.