Faceted Aggregation in MongoDB: $facet, $bucket, and $bucketAuto
A common pattern in dashboards and search UIs is needing several summaries of the same data at once: a price histogram, a count by category, and a top-10 list, all computed over the same filtered set of documents. Running three separate aggregations means reading the same documents three times. MongoDB's $facet stage solves this by running multiple sub-pipelines in a single pass. Its companions $bucket and $bucketAuto group documents into ranges, which is what most facets actually are.
This guide covers all three stages, how they fit together, and the restrictions that surprise people.
$bucket: group documents into ranges you define
$group puts documents into groups by exact key. $bucket puts them into ranges. You supply the boundaries:
db.listings.aggregate([
{
$bucket: {
groupBy: "$price",
boundaries: [0, 100, 200, 500, 1000],
default: "other",
output: {
count: { $sum: 1 },
avgRating: { $avg: "$rating" }
}
}
}
])This produces one document per bucket. The _id of each output document is the inclusive lower bound of its bucket; the upper bound is exclusive:
{ "_id": 0, "count": 412, "avgRating": 4.1 } // 0 <= price < 100
{ "_id": 100, "count": 233, "avgRating": 4.3 } // 100 <= price < 200
{ "_id": 200, "count": 95, "avgRating": 4.4 } // 200 <= price < 500
{ "_id": 500, "count": 11, "avgRating": 4.6 } // 500 <= price < 1000
{ "_id": "other", "count": 7, "avgRating": 3.9 } // everything elseRules worth knowing:
- Boundaries must be sorted ascending and all the same type (all numbers, all dates, and so on).
- Every document must land somewhere. If a document's
groupByvalue falls outside the boundaries (or is a different BSON type, including missing), the aggregation errors unless you providedefault. In practice you almost always wantdefault. - The
defaultvalue can be any literal, and it does not need to fit the boundary type."other"as a string label is the usual choice. outputis optional. Without it you getcountonly.
$bucketAuto: let MongoDB pick the boundaries
When you do not know the data's distribution, $bucketAuto distributes documents into a requested number of buckets, as evenly as it can:
db.listings.aggregate([
{
$bucketAuto: {
groupBy: "$price",
buckets: 5,
output: { count: { $sum: 1 } }
}
}
])Output _id values are { min, max } documents instead of a scalar lower bound:
{ "_id": { "min": 5, "max": 87 }, "count": 151 }
{ "_id": { "min": 87, "max": 159 }, "count": 150 }
// ...Two behaviors to know:
- "Evenly" means even document counts, not even ranges. With skewed data you get narrow buckets where data is dense and wide buckets where it is sparse. If you need fixed-width ranges, use
$bucket. - The optional
granularityoption (e.g."R5","R10","E24","POWERSOF2") rounds boundaries to a preferred number series so they look presentable (10, 25, 40 instead of 11.3, 24.7, 39.2). It only works with numeric, non-negativegroupByvalues.
You may also get fewer buckets than requested if there are not enough distinct values to fill them.
$facet: multiple pipelines, one pass
$facet takes the documents flowing into it and feeds the same set into several named sub-pipelines. The output is a single document with one array field per sub-pipeline:
db.listings.aggregate([
{ $match: { city: "Geneva", active: true } },
{
$facet: {
priceHistogram: [
{ $bucket: { groupBy: "$price", boundaries: [0, 100, 200, 500], default: "other" } }
],
byCategory: [
{ $group: { _id: "$category", count: { $sum: 1 } } },
{ $sort: { count: -1 } }
],
topRated: [
{ $sort: { rating: -1 } },
{ $limit: 10 },
{ $project: { title: 1, rating: 1 } }
],
total: [
{ $count: "n" }
]
}
}
])Result shape:
{
"priceHistogram": [ /* bucket docs */ ],
"byCategory": [ /* group docs */ ],
"topRated": [ /* 10 docs */ ],
"total": [ { "n": 751 } ]
}This is the classic faceted-search shape: one query, every sidebar widget populated.
The restrictions that matter
- Sub-pipelines inside
$facetcannot use indexes. Index-eligible stages ($matchwith an index,$sortusing an index,$geoNear) only benefit from indexes before the$facetstage. Do your selective$matchfirst, then facet the survivors. A$matchinside a facet sub-pipeline is a full scan of whatever flowed in. - The entire output is one document, so it is subject to the 16 MB BSON document limit. A facet that returns thousands of documents in one of its arrays will blow up. Keep sub-pipelines aggregational (
$group,$bucket,$count,$limit), not document-dumping. - Certain stages are not allowed inside
$facet:$facetitself (no nesting),$out,$merge,$indexStats,$collStats, and$geoNear(it must be the first stage of the whole pipeline). - Each sub-pipeline gets the same input documents once;
$facetdoes not re-read the collection per sub-pipeline. That is the entire point, and why it is cheaper than N separate queries.
Putting it together: a faceted product search
db.products.aggregate([
// 1. Selective filter first: this is the only place indexes help
{ $match: { categoryPath: /^electronics/, inStock: true } },
// 2. One pass, three facets
{
$facet: {
brands: [
{ $group: { _id: "$brand", count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 20 }
],
priceRanges: [
{
$bucket: {
groupBy: "$price",
boundaries: [0, 50, 100, 250, 500, 1000],
default: "1000+",
output: { count: { $sum: 1 } }
}
}
],
results: [
{ $sort: { popularity: -1 } },
{ $skip: 0 },
{ $limit: 24 },
{ $project: { name: 1, price: 1, brand: 1, thumbnail: 1 } }
]
}
}
])Pagination lives inside the results facet ($skip/$limit), while the sidebar facets always reflect the full filtered set. That consistency is awkward to achieve with separate queries and trivial here.
Common mistakes
Filtering inside the facet instead of before it. A $match as the first stage of the pipeline can use an index; the same $match inside a sub-pipeline cannot. Push every filter that applies to all facets above the $facet stage.
Forgetting default in $bucket. One document with a missing or out-of-range groupBy value fails the whole aggregation. Add default unless you have validated the field.
Using $bucketAuto and expecting fixed-width ranges. It optimizes for even counts. Histograms with equal-width bars need $bucket with explicit boundaries.
Returning raw documents from every facet. Remember the single 16 MB output document. Facets should mostly return aggregates; cap any document list with $limit.
Reaching for $facet when you only need one pipeline. If there is just one summary, a plain $group or $bucket is simpler and can be index-assisted throughout.
Debugging
explain("executionStats") on a faceted aggregation shows the winning plan for the stages before $facet; check that your leading $match shows an IXSCAN there. For memory issues, sub-pipelines respect the usual 100 MB per-stage limit; pass allowDiskUse: true if a $sort or $group inside a facet exceeds it. See our aggregation pipeline guide for explain-output basics.
Related guides
- The MongoDB Aggregation Pipeline Explained
- MongoDB Indexes Explained
- $unwind and Working with Arrays
- Joins with $lookup
Mako connects to MongoDB with AI-powered autocomplete that knows aggregation stage syntax. 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.