MongoDB$group Stage Deep Dive

$group

$group is the aggregation framework's answer to SQL's GROUP BY. It collapses many input documents into one output document per distinct value of a grouping key, computing an accumulator expression (sum, average, count, etc.) for each group along the way.

The _id Grouping Key

Every $group stage requires an _id field — this is the grouping key, not a document identifier. It can be a single field reference, a compound object of several fields, or a computed expression.

Grouping by a single field

JS
db.orders.aggregate([
  { $group: { _id: "$customer", orderCount: { $sum: 1 } } }
])
// [ { _id: "Alice", orderCount: 2 }, { _id: "Carol", orderCount: 1 } ]

Grouping by a compound key

JS
db.orders.aggregate([
  { $group: { _id: { customer: "$customer", status: "$status" }, count: { $sum: 1 } } }
])
// [ { _id: { customer: "Alice", status: "shipped" }, count: 2 }, ... ]

Grouping by null — one group for the whole collection

JS
db.orders.aggregate([
  { $group: { _id: null, grandTotal: { $sum: "$total" } } }
])
// [ { _id: null, grandTotal: 165.49 } ] — a single summary document
Accumulator Operators

Accumulator

Result

$sum

Sum of a numeric expression (or count with $sum: 1)

$avg

Arithmetic mean of a numeric expression

$min / $max

Smallest / largest value in the group

$push

Collects every value into an array (duplicates kept)

$addToSet

Collects every unique value into an array (duplicates removed)

$first / $last

First / last value seen, per group, following the input document order (usually paired with a preceding $sort)

$count

Available as a distinct top-level stage — or { $sum: 1 } inside $group

Several accumulators in one $group

JS
db.orders.aggregate([
  {
    $group: {
      _id: "$customer",
      orderCount: { $sum: 1 },
      totalSpent: { $sum: "$total" },
      avgOrderValue: { $avg: "$total" },
      biggestOrder: { $max: "$total" },
      statuses: { $addToSet: "$status" }
    }
  }
])
[
  {
    _id: 'Alice',
    orderCount: 2,
    totalSpent: 57.5,
    avgOrderValue: 28.75,
    biggestOrder: 42.5,
    statuses: [ 'shipped' ]
  },
  {
    _id: 'Carol',
    orderCount: 1,
    totalSpent: 88,
    avgOrderValue: 88,
    biggestOrder: 88,
    statuses: [ 'shipped' ]
  }
]
$first and $last Need a Preceding $sort
Warning
$first and $last return whatever document happened to arrive first/last into the group — with no guaranteed order unless you add an explicit $sort stage before the $group. Always sort first when the "first" or "last" value needs to mean something specific (e.g. most recent order).

Deterministic $first with a preceding $sort

JS
db.orders.aggregate([
  { $sort: { customer: 1, createdAt: -1 } },   // newest first, within each customer
  { $group: { _id: "$customer", mostRecentOrderId: { $first: "$_id" } } }
])
Grouping by Date Parts

Combine $group's _id with a date expression like $dateToString to bucket documents by day, month, or any other calendar granularity.

Revenue per day

JS
db.orders.aggregate([
  {
    $group: {
      _id: { $dateToString: { format: "%Y-%m-%d", date: "$createdAt" } },
      dailyRevenue: { $sum: "$total" },
      orders: { $sum: 1 }
    }
  },
  { $sort: { _id: 1 } }
])
// [ { _id: "2024-01-01", dailyRevenue: 340.50, orders: 6 }, ... ]

Grouping by year and month with date operators

JS
db.orders.aggregate([
  {
    $group: {
      _id: { year: { $year: "$createdAt" }, month: { $month: "$createdAt" } },
      monthlyRevenue: { $sum: "$total" }
    }
  },
  { $sort: { "_id.year": 1, "_id.month": 1 } }
])
Multiple Aggregates in One Pass

A single $group stage scans the input once, no matter how many accumulator fields you compute — this is significantly cheaper than running several separate aggregation queries.

Everything in one scan

JS
db.orders.aggregate([
  { $match: { status: "shipped" } },
  {
    $group: {
      _id: "$customer",
      totalSpent: { $sum: "$total" },
      avgOrderValue: { $avg: "$total" },
      orderCount: { $sum: 1 },
      lineItemsPurchased: { $push: "$items" }
    }
  }
])
Filtering After Grouping (the HAVING Equivalent)

SQL's HAVING filters on the result of an aggregate — in the pipeline model, that's simply a second $match stage placed after $group.

$match after $group

JS
db.orders.aggregate([
  { $group: { _id: "$customer", totalSpent: { $sum: "$total" } } },
  { $match: { totalSpent: { $gte: 50 } } }   // the "HAVING" equivalent
])
// only groups whose total is >= 50 survive
Note
A $match before $group filters raw input documents (the SQL WHERE equivalent); a $match after $group filters the computed group results (the SQL HAVING equivalent). They serve different purposes and are often used together in the same pipeline.
Tip
$group stages that don't fit in memory (default 100 MB) will error unless you pass { allowDiskUse: true } as an aggregation option, which lets MongoDB spill intermediate results to temporary files on disk.
  • _id in $group is the grouping key — a field, compound object, or expression, not a document identifier.

  • $sum, $avg, $min/$max, $push, $addToSet, $first/$last are the core accumulators.

  • $first/$last need a preceding $sort to be deterministic.

  • A $match after $group is the aggregation equivalent of SQL's HAVING.