$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
db.orders.aggregate([
{ $group: { _id: "$customer", orderCount: { $sum: 1 } } }
])
// [ { _id: "Alice", orderCount: 2 }, { _id: "Carol", orderCount: 1 } ]Grouping by a compound key
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
db.orders.aggregate([
{ $group: { _id: null, grandTotal: { $sum: "$total" } } }
])
// [ { _id: null, grandTotal: 165.49 } ] — a single summary documentAccumulator Operators
Accumulator | Result |
|---|---|
$sum | Sum of a numeric expression (or count with |
$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 |
$count | Available as a distinct top-level stage — or |
Several accumulators in one $group
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
$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
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
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
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
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
db.orders.aggregate([
{ $group: { _id: "$customer", totalSpent: { $sum: "$total" } } },
{ $match: { totalSpent: { $gte: 50 } } } // the "HAVING" equivalent
])
// only groups whose total is >= 50 survive$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.$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._idin$groupis the grouping key — a field, compound object, or expression, not a document identifier.$sum,$avg,$min/$max,$push,$addToSet,$first/$lastare the core accumulators.$first/$lastneed a preceding$sortto be deterministic.A
$matchafter$groupis the aggregation equivalent of SQL'sHAVING.