skip to content

How does $facet let one pipeline compute several independent aggregations over the same input?

level: seniorimportance: should knowfreq 38%

answer

  1. several summaries of one filtered set
  2. every branch sees the same input
  3. the result is a single document
  4. each facet's value is an array
  5. the selective filter belongs outside

basics

~20 s

$facet runs several named sub-pipelines over the same input documents in one pass and returns a single document with one array per facet — typically a page of results, a total count and category buckets in one round trip.

solid answer

~40 s

`$facet` takes a document of name-to-sub-pipeline pairs. Every sub-pipeline receives **the same** input stream — the documents that reached the `$facet` stage — and its output becomes an array under its name in a **single** output document. The classic use is a search results page: one facet does `$skip`/`$limit` for the page, one does `$count` for the total, and others do `$sortByCount` or `$bucket` for the filter sidebar, all from one query. Three constraints matter. The whole result is one BSON document, so all facets together must fit the 16 MB document limit — never put an unbounded facet in there. Stages inside `$facet` cannot use indexes, so any selective `$match` belongs *before* the `$facet`. And sub-pipelines cannot nest another `$facet` or write with `$out`/`$merge`.

code

javascript · 14 lines
javascript
db.products.aggregate([
  { $match: { category: "laptops", inStock: true } },   // indexed, outside the facet
  { $facet: {
      page:    [ { $sort: { price: 1 } }, { $skip: 0 }, { $limit: 20 } ],
      total:   [ { $count: "n" } ],
      byBrand: [ { $sortByCount: "$brand" } ],
      byPrice: [ { $bucket: {
                    groupBy: "$price",
                    boundaries: [0, 500, 1000, 2000],
                    default: "2000+",
                    output: { n: { $sum: 1 } }
                } } ]
  } }
])

go deeper

for a junior

Know what the stage is for: several summaries of the same filtered documents returned together, with each facet's result appearing as an array in one output document.

for a middle

Explain that every sub-pipeline receives the identical input stream, describe the single-document output shape, and predict what a count facet returns when nothing matches.

for a senior

Demonstrate the operational judgment: filter before the stage because indexes are unavailable inside it, bound every facet against the 16 MB single-document limit, and know which stages are disallowed in a sub-pipeline.

for a principal

Own whether faceted counts should be computed live at all. Slow-changing sidebar counts are cacheable or pre-aggregated; exact totals over huge match sets are often the wrong requirement rather than the wrong query.

## The problem it solves A faceted search page needs several different summaries of the *same* filtered result set: the current page of documents, the total match count for pagination, and counts per category, per price band, per brand for the filter sidebar. Running those as separate queries means re-executing the same filter several times and risking inconsistency if data changes between calls. `$facet` computes them in one pass over one input stream. ## Shape ```javascript db.products.aggregate([ { $match: { category: "laptops", inStock: true } }, { $facet: { page: [ { $sort: { price: 1 } }, { $skip: 0 }, { $limit: 20 } ], total: [ { $count: "n" } ], byBrand: [ { $sortByCount: "$brand" } ], byPrice: [ { $bucket: { groupBy: "$price", boundaries: [0, 500, 1000, 2000], default: "2000+", output: { n: { $sum: 1 } } } } ] } } ]) ``` The output is **one** document: ```json { "page": [ /* 20 docs */ ], "total": [ { "n": 137 } ], "byBrand": [ { "_id": "acme", "count": 40 }, … ], "byPrice": [ { "_id": 0, "n": 12 }, … ] } ``` Note the shapes: every facet is an array, even when it holds one element. `total` is `[{ n: 137 }]`, not `137`, and a `$count` facet over an empty input produces an **empty array** — application code that reads `total[0].n` blindly will throw on a zero-result search. Reading it defensively, or unwrapping it with a following `{ $set: { total: { $ifNull: [{ $arrayElemAt: ["$total.n", 0] }, 0] } } }`, is the usual fix. ## Same input, independent pipelines Each sub-pipeline sees exactly the documents that arrived at the `$facet` stage, unaffected by what the other sub-pipelines do. That independence is the whole point, and it is also the reason `$facet` cannot reduce the work: the input is materialised once and fed to every branch, so ten facets mean ten traversals of that materialised set. ## The three constraints worth naming **Single-document output and the 16 MB limit.** Because all facets land in one BSON document, their combined size is bounded by the 16 MB document limit. A facet that returns unbounded results — no `$limit`, or a `$group` producing millions of buckets — will make the whole aggregation fail, taking the useful facets down with it. Every facet should have a bound: a `$limit`, a `$count`, or a grouping key with known low cardinality. **No index use inside.** MongoDB documents that `$facet` and its sub-pipelines cannot make use of indexes, even when a sub-pipeline starts with `$match`. Practically: the selective filter goes **before** the `$facet`, where the planner can pick an index; a `$match` inside a facet is a per-document filter over the already-materialised input. Getting this backwards — putting the search predicate inside each facet — is the classic performance bug with this stage. **Disallowed stages.** Sub-pipelines cannot nest another `$facet`, and cannot write with `$out` or `$merge`. Stages that must be first in a pipeline, such as `$geoNear` and the `$…Stats` diagnostics, are also unavailable inside. ## $bucket and $bucketAuto, the usual facet contents `$bucket` groups documents into ranges you specify: `groupBy` is the expression, `boundaries` is a sorted array of same-type edges (each bucket is lower-inclusive, upper-exclusive), `output` holds accumulators exactly as in `$group`, and `default` names the bucket for values falling outside the boundaries. Without a `default`, a document outside the boundaries makes the stage **error** — a real production hazard when new data drifts past your highest edge, and a good reason to always supply one. `$bucketAuto` instead takes a `buckets` count and picks boundaries itself to distribute documents as evenly as it can, with an optional `granularity` that snaps edges to a preferred number series. Use `$bucket` when the ranges are business-defined (price bands you display), `$bucketAuto` when you just want an even distribution for a histogram. ## When not to use it `$facet` is the right answer when the facets genuinely share an input and you want one round trip and one consistent snapshot. It is the wrong answer when one facet is expensive and the others are cheap but you need them at different cadences — sidebar counts often change slowly and can be cached or pre-aggregated, while the result page must be live. It is also the wrong answer for a plain "results plus total count" pair on a very large match set, where computing an exact total is the expensive part regardless of stage; approximate or capped counts are the usual production compromise. And note that a page of results plus its total is fundamentally offset pagination — for deep pages, keyset paging outperforms it, facet or not.

  • Why should a selective $match go before the $facet rather than inside each sub-pipeline?
    Stages inside `$facet` cannot use indexes, so a `$match` there is a per-document filter over the already-materialised input. Placed before the `$facet`, the same predicate is index-eligible and shrinks the input every branch has to traverse. Putting the search filter inside each facet is the classic way to make this stage slow.
  • What happens to a $count facet when nothing matches, and why does that break clients?
    Every facet is an array, and `$count` over an empty input yields an empty array — `total` comes back as `[]`, not `[{ n: 0 }]`. Client code reading `total[0].n` throws on any zero-result search. Unwrap it in the pipeline with `$arrayElemAt` plus `$ifNull`, or handle the empty array explicitly.
  • How do $bucket and $bucketAuto differ, and what is the trap in $bucket?
    `$bucket` uses boundaries you supply, lower-inclusive and upper-exclusive; `$bucketAuto` takes a bucket count and chooses edges itself for an even spread. The trap: a document whose groupBy value falls outside `$bucket`'s boundaries makes the stage error unless you supply a `default` bucket — which data drift will eventually cause, so always supply one.
  • Why can a $facet aggregation fail on a large collection even when each facet looks reasonable?
    All facets are returned inside one BSON document, so their combined size is bounded by the 16 MB document limit. An unbounded facet — no `$limit`, or a grouping key with very high cardinality — can push the single result document past that limit and fail the entire aggregation, including the facets that were fine.

saying these in an interview costs you the question

  • Thinks each sub-pipeline gets a different subset of the input
  • Puts the selective $match inside every facet
  • Expects a count facet to return a number rather than an array
  • Assumes $facet reduces the work versus separate queries
  • Uses $bucket without a default and is surprised by errors

context