$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" }), or null to 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 $group so the expensive grouping step processes as few documents as possible.
  • Use $sum: 1 or the $count accumulator purely to count documents per group; reserve $sum: "$field" for actual numeric totals.
  • Add { allowDiskUse: true } as a second argument to aggregate() 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 $addToSet over $push when you only care about distinct values, since it avoids storing duplicates.
  • Check explain("executionStats") on the pipeline to confirm the pre-$group $match is using IXSCAN rather than a full COLLSCAN.

Practice Exercises

  • Given db.orders, write an aggregation that returns the total revenue and number of orders for the entire collection (hint: use _id: null to produce one overall document).
  • Group db.orders by status and return only statuses whose orderCount is greater than 10 (hint: you’ll need a $match stage after $group, since the pre-group $match can’t filter on an accumulated value).
  • Group db.orders by customer and collect the distinct set of product categories each customer has ordered from, using an accumulator that avoids duplicate values.

Summary

  • $group buckets documents by an _id expression and reduces each bucket to one output document using accumulator operators.
  • Common accumulators include $sum, $avg, $min, $max, $push, $addToSet, $first, and $last.
  • $group is a blocking stage that must see every document in a bucket before producing that bucket’s result, capped at 100MB of memory unless allowDiskUse: true is set.
  • Fields not referenced in _id or an accumulator are dropped from the output entirely.
  • Filter early with $match before $group to shrink the working set and use available indexes.