Faceted Aggregation in MongoDB: $facet, $bucket, and $bucketAuto

7 min readMongoDB

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 else

Rules 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 groupBy value falls outside the boundaries (or is a different BSON type, including missing), the aggregation errors unless you provide default. In practice you almost always want default.
  • The default value can be any literal, and it does not need to fit the boundary type. "other" as a string label is the usual choice.
  • output is optional. Without it you get count only.

$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 granularity option (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-negative groupBy values.

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 $facet cannot use indexes. Index-eligible stages ($match with an index, $sort using an index, $geoNear) only benefit from indexes before the $facet stage. Do your selective $match first, then facet the survivors. A $match inside 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: $facet itself (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; $facet does not re-read the collection per sub-pipeline. That is the entire point, and why it is cheaper than N separate queries.
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.


Mako connects to MongoDB with AI-powered autocomplete that knows aggregation stage syntax. 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.