Skip to content

Facets and buckets

The last stages in this module answer questions that need several summaries at once. $facet runs many sub-pipelines side by side over the same input, so you get a page of results, the total count and a breakdown in one request. $bucket and $bucketAuto group documents into ranges, the way a histogram sorts values into bins. Together they power the “dashboard” query: one request, many panels.

The lesson seeds the module’s sales collection (200 000 orders { title, genre, quantity, price, buyer }, index on price) and the aplan() helper from the module intro. Captured on mongo 8.3.11, mongosh 2.11.1 and Node driver 7.7.0 on one Mac; Python, Go and Rust tabs were each run once in a scratch program.

mongosh/fb-seed.js
const genres = ["fiction", "science", "history", "poetry", "comics"];
globalThis.aplan = (pipeline) => { // one line per aggregate explain: what the first stage read, then the stages left in the pipeline
const e = db.sales.explain("executionStats").aggregate(pipeline);
const cursor = e.stages ? e.stages[0].$cursor : e, rest = e.stages ? e.stages.slice(1).map((s) => Object.keys(s)[0]) : [];
const chain = (p) => { const out = []; for (p = p.queryPlan ?? p; p; p = p.inputStage) out.push(p.stage + (p.indexName ? "(" + p.indexName + ")" : "")); return out.join(" > "); };
const s = cursor.executionStats;
return chain(cursor.queryPlanner.winningPlan) + " keys=" + s.totalKeysExamined + " docs=" + s.totalDocsExamined + (rest.length ? " | left in pipeline: " + rest.join(" > ") : "");
};
for (let s = 0; s < 200000; s += 50000) {
db.sales.insertMany(Array.from({ length: 50000 }, (_, k) => {
const i = s + k; // 500 titles, 5 genres, quantity 1..5, price 5..25, 1000 buyers (pseudo-random but repeatable)
return { title: "T" + (i % 500), genre: genres[(i % 500) % 5], quantity: 1 + ((Math.imul(i, 2654435761) >>> 0) % 5), price: 5 + ((Math.imul(i, 40503) >>> 0) % 21), buyer: "b" + (i % 1000) };
}), { ordered: false });
}
db.sales.createIndex({ price: 1 });
console.log("sales:", db.sales.countDocuments(), "orders");
sales: 200000 orders

$facet runs sub-pipelines over the same input

Section titled “$facet runs sub-pipelines over the same input”

$facet takes an object whose keys are names you choose and whose values are sub-pipelines. The manual says each is “passed the exact same set of input documents” ($facet, consulted 2026-09-30). The output is one document with a field per facet. Below: the two highest-priced fiction orders, the total, and a breakdown by quantity, all for fiction orders priced 24 or more. The Node.js tab builds a results-plus-count page on its own 100-order collection:

flowchart LR
  In["sales documents"] --> F["$facet"]
  F --> A["topOrders sub-pipeline"]
  F --> B["totalCount sub-pipeline"]
  F --> C["byQuantity sub-pipeline"]
  A --> Out["one document with three fields"]
  B --> Out
  C --> Out
One stage, several sub-pipelines over the same input, merged into a single result document
mongosh/fb-facet.js
const [r] = db.sales.aggregate([
{ $match: { genre: "fiction", price: { $gte: 24 } } },
{ $facet: {
topOrders: [{ $sort: { price: -1, _id: 1 } }, { $limit: 2 }, { $project: { _id: 0, title: 1, price: 1 } }],
totalCount: [{ $count: "orders" }],
byQuantity: [{ $group: { _id: "$quantity", count: { $sum: 1 } } }, { $sort: { _id: 1 } }],
} },
]).toArray();
console.log(r);
assert.strictEqual(r.byQuantity.reduce((n, g) => n + g.count, 0), r.totalCount[0].orders);
{
topOrders: [ { title: 'T60', price: 25 }, { title: 'T95', price: 25 } ],
totalCount: [ { orders: 2684 } ],
byQuantity: [
{ _id: 1, count: 537 },
{ _id: 2, count: 536 },
{ _id: 3, count: 539 },
{ _id: 4, count: 536 },
{ _id: 5, count: 536 }
]
}
{"page":[{"title":"T19","price":24},{"title":"T39","price":24},{"title":"T59","price":24}],"total":[{"n":50}]}

In Compass: $facet is available in the Aggregations tab, and each sub-pipeline can be built and previewed before you combine them.

The manual: if $facet is the first stage it performs a COLLSCAN, while a $match or $sort before it “can make use of indexes and the $facet will not trigger a COLLSCAN”. The sub-pipelines themselves never get the collection’s indexes:

mongosh/fb-facet-index.js
const facets = { a: [{ $count: "n" }], b: [{ $group: { _id: "$genre", c: { $sum: 1 } } }] };
console.log("$match before $facet :", aplan([{ $match: { price: { $gte: 24 } } }, { $facet: facets }]));
console.log("$match inside a facet :", aplan([{ $facet: { a: [{ $match: { price: { $gte: 24 } } }, { $count: "n" }] } }]));
console.log("$sort+$limit in facet :", aplan([{ $facet: { top: [{ $sort: { price: 1 } }, { $limit: 5 }] } }]));
console.log("same sort+limit, find :", (() => { const s = db.sales.find().sort({ price: 1 }).limit(5).explain("executionStats").executionStats; return "keys=" + s.totalKeysExamined + " docs=" + s.totalDocsExamined; })());
$match before $facet : PROJECTION_SIMPLE > FETCH > IXSCAN(price_1) keys=13422 docs=13422 | left in pipeline: $facet
$match inside a facet : PROJECTION_SIMPLE > COLLSCAN keys=0 docs=200000 | left in pipeline: $facet
$sort+$limit in facet : COLLSCAN keys=0 docs=200000 | left in pipeline: $facet
same sort+limit, find : keys=5 docs=5

The whole $facet result is one document, so it is subject to the BSON limit (“If it exceeds 16 mebibytes, the aggregation produces an error”; the old lesson said the limit was per facet). Returning everything from one facet on 200 000 orders (about 20.9 MB) fails, and bounding the facet fixes it:

mongosh/fb-16mb.js
try { db.sales.aggregate([{ $facet: { everything: [{ $match: {} }] } }]).toArray(); }
catch (e) { console.log(e.codeName + ":", e.message.slice(e.message.indexOf("Serializing")).slice(0, 110)); }
const ok = db.sales.aggregate([{ $facet: { everything: [{ $match: {} }, { $limit: 1000 }, { $project: { title: 1 } }], n: [{ $count: "n" }] } }]).toArray()[0];
console.log("bounded facet:", ok.everything.length, "rows +", JSON.stringify(ok.n));
BSONObjectTooLarge: Serializing Document failed :: caused by :: Size 20942912 exceeds maximum 16793600
bounded facet: 1000 rows + [{"n":200000}]

$bucket bins documents by boundaries you supply: a groupBy expression, an ascending boundaries array, an optional default for values outside, and an output of accumulators. Each bucket’s _id is its lower bound (inclusive; the upper bound is exclusive). Without a default, a value outside the boundaries is an error:

mongosh/fb-bucket.js
const b = { groupBy: "$price", boundaries: [5, 10, 20], output: { orders: { $sum: 1 }, avgQty: { $avg: "$quantity" } } };
console.log("with default :", JSON.stringify(db.sales.aggregate([{ $bucket: { ...b, default: "other" } }, { $project: { orders: 1, avgQty: { $round: ["$avgQty", 2] } } }]).toArray()));
try { db.sales.aggregate([{ $bucket: b }]).toArray(); } catch (e) { console.log("no default :", e.codeName + " -", e.message.slice(e.message.indexOf("$switch"))); }
const total = db.sales.aggregate([{ $bucket: { ...b, default: "other" } }]).toArray().reduce((n, x) => n + x.orders, 0);
assert.strictEqual(total, 200000);
with default : [{"_id":5,"orders":43721,"avgQty":3},{"_id":10,"orders":99136,"avgQty":3},{"_id":"other","orders":57143,"avgQty":3}]
no default : Location7158303 - $switch could not find a matching branch for an input, and no default was specified.

$bucketAuto chooses boundaries to spread documents over the number of buckets you ask for. It returns each bucket’s min and max in _id, but the sizes are only as even as the data allows:

mongosh/fb-bucketauto.js
const rows = db.sales.aggregate([{ $bucketAuto: { groupBy: "$price", buckets: 4 } }]).toArray();
console.log(rows.map((r) => "[" + r._id.min + ", " + r._id.max + (r === rows[rows.length - 1] ? "]" : ")") + " " + r.count).join(" "));
const wholeValues = rows.every((r, i) => r.count === db.sales.countDocuments({ price: i === rows.length - 1 ? { $gte: r._id.min, $lte: r._id.max } : { $gte: r._id.min, $lt: r._id.max } }));
console.log("every bucket equals a whole range of price values (no value split across buckets):", wholeValues, "- so sizes cannot be forced to 50 000 each");
assert.ok(wholeValues);
assert.strictEqual(rows.reduce((n, r) => n + r.count, 0), 200000);
[5, 11) 57144 [11, 17) 57143 [17, 23) 57142 [23, 25] 28571
every bucket equals a whole range of price values (no value split across buckets): true - so sizes cannot be forced to 50 000 each

A $facet saves round trips, not server work: it runs every sub-pipeline over the input. Three panels as one facet versus three separate requests on the same machine:

mongosh/fb-facet-vs-queries.js
const ms = (f) => { const t = Date.now(); f(); return Date.now() - t; };
const facet = ms(() => db.sales.aggregate([{ $match: { genre: "fiction" } }, { $facet: {
page: [{ $sort: { price: 1, _id: 1 } }, { $limit: 10 }], total: [{ $count: "n" }], byQty: [{ $group: { _id: "$quantity", c: { $sum: 1 } } }] } }]).toArray());
db.sales.createIndex({ genre: 1, price: 1, _id: 1 });
const facetIdx = ms(() => db.sales.aggregate([{ $match: { genre: "fiction" } }, { $facet: {
page: [{ $sort: { price: 1, _id: 1 } }, { $limit: 10 }], total: [{ $count: "n" }], byQty: [{ $group: { _id: "$quantity", c: { $sum: 1 } } }] } }]).toArray());
const separate = ms(() => {
db.sales.find({ genre: "fiction" }).sort({ price: 1, _id: 1 }).limit(10).toArray();
db.sales.countDocuments({ genre: "fiction" });
db.sales.aggregate([{ $match: { genre: "fiction" } }, { $group: { _id: "$quantity", c: { $sum: 1 } } }]).toArray();
});
console.log({ facetOneRequest: facet, facetWithCompoundIndex: facetIdx, threeSeparateRequests: separate });
{
facetOneRequest: 85,
facetWithCompoundIndex: 79,
threeSeparateRequests: 53
}

Locally the separate, indexed requests were faster (there is no network latency to save). Over a real network two extra round trips cost more than that difference, and a single request reads one consistent snapshot - so measure with your own latency before choosing.

  • Filter before the $facet so every facet shares a narrowed, index-served input; do not repeat the $match inside each facet.
  • The facet output is one document under the 16 MiB BSON limit: bound big facets with $limit/$project/$group.
  • $sort + $limit inside a facet cannot use an index; a paged list is often better as find + a count.
  • $bucket boundaries must ascend; a value outside them needs a default or the stage fails.
  • $bucketAuto never splits identical values, so sizes are uneven when values repeat.

Asked for a paged list with a total “in one request”, an assistant puts the whole thing in a $facet:

🐛 Spot the bug

// AI comment: "one request for the page and the total"
db.sales.aggregate([
{ $facet: {
items: [{ $sort: { price: 1, _id: 1 } }, { $skip: 100 }, { $limit: 10 }],
total: [{ $count: "n" }],
} },
]);
Show the answer

It returns the right rows, but the $facet is the first stage, so it scans the whole collection and sorts in memory: 200 000 documents read and 134 ms, against 10 documents and 4 ms for a plain find with the { price: 1, _id: 1 } index plus estimatedDocumentCount(). The fence asserts that both return the same ten rows. Use find (or a $match/$sort/$skip/$limit pipeline) for the page, and count separately.

mongosh/fb-bug.js
db.sales.createIndex({ price: 1, _id: 1 }); // the index a paged listing sorted by price would use
const buggy = [{ $facet: { items: [{ $sort: { price: 1, _id: 1 } }, { $skip: 100 }, { $limit: 10 }], total: [{ $count: "n" }] } }]; // "results + total in one request"
const time = (f) => { const t = Date.now(); const r = f(); return [r, Date.now() - t]; };
const [a, aMs] = time(() => db.sales.aggregate(buggy).toArray()[0]);
const [b, bMs] = time(() => ({ items: db.sales.find().sort({ price: 1, _id: 1 }).skip(100).limit(10).toArray(), total: db.sales.estimatedDocumentCount() }));
console.log("$facet, sort inside :", a.total[0].n, "total,", a.items.length, "rows in", aMs, "ms");
console.log("find + estimatedCount:", b.total, "total,", b.items.length, "rows in", bMs, "ms");
const st = db.sales.explain("executionStats").aggregate(buggy);
const c = st.stages ? st.stages[0].$cursor : st;
console.log("the facet read", c.executionStats.totalDocsExamined, "documents; the find read", db.sales.find().sort({ price: 1, _id: 1 }).skip(100).limit(10).explain("executionStats").executionStats.totalDocsExamined);
assert.deepStrictEqual(a.items.map((d) => d._id), b.items.map((d) => d._id));
$facet, sort inside : 200000 total, 10 rows in 134 ms
find + estimatedCount: 200000 total, 10 rows in 4 ms
the facet read 200000 documents; the find read 10

$facet is a fan-out: the input is read once and handed to several independent mini-pipelines whose results are stapled into one document. Everything before it can use the collection’s indexes; nothing inside it can, and the stapled document has a hard size cap.

ChoiceBenefitCost
$facetSeveral summaries, one request, one snapshotOutput capped at 16 MiB; sub-pipelines cannot use indexes
Separate queriesEach uses its best index (53 ms vs 85 ms locally)More round trips; results from different moments
$bucketBoundaries match business bandsYou maintain the boundaries; a default is required for strays
$bucketAutoNo need to know the distributionUneven sizes; boundaries shift with the data
  • “The 16 MiB limit is per facet.” - it applies to the whole output document.
  • “$facet can never use an index.” - a $match before it did (13 422 keys); only its inside cannot.
  • “One $facet is cheaper than three queries.” - it saves round trips, not work (85 ms vs 53 ms here).
  • “$bucket skips out-of-range values.” - it fails without a default.
  • “$bucketAuto gives equal buckets.” - 57 144 / 57 143 / 57 142 / 28 571.

Real-world example

The manual’s $facet page states both limits proven here: “the $facet stage does not make use of indexes if it is the first stage in the pipeline” and “the final output document is subject to the 16 mebibyte BSON document size limit” ($facet, consulted 2026-09-30). Keller Williams uses this style of summary pipeline over its listing data on Atlas (mongodb.com/customers/keller-williams).

Python/Go/Rust tabs mirror the verified shell/Node behaviour; each was run once in a scratch program against the same server (the facet returned a page and a total) but they are not executed by the course’s automated checker.

What does each sub-pipeline of $facet receive as input?
Where does an index help a pipeline containing $facet?
$facet: { everything: [ { $match: {} } ] } on 200 000 orders fails. Why?
$bucket with boundaries [5, 10, 20] and no default meets a price of 25. What happens?
Why are $bucketAuto buckets not equal in size?
A paged list is built as one $facet with $sort/$skip/$limit and $count. What did it cost compared with find + estimatedDocumentCount?