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.
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
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);const sales = db.collection("sales");await sales.insertMany(Array.from({ length: 100 }, (_, i) => ({ title: "T" + i, genre: i % 2 ? "fiction" : "science", price: 5 + (i % 20) })));const [r] = await sales.aggregate([ { $match: { genre: "fiction" } }, { $facet: { page: [{ $sort: { price: -1, title: 1 } }, { $limit: 3 }, { $project: { _id: 0, title: 1, price: 1 } }], total: [{ $count: "n" }] } },]).toArray();console.log(JSON.stringify(r));if (r.total[0].n !== 50 || r.page.length !== 3) throw new Error("unexpected facet");rows = list(db.sales.aggregate([ {"$match": {"genre": "fiction"}}, {"$facet": { "page": [{"$sort": {"price": -1, "title": 1}}, {"$limit": 3}], "total": [{"$count": "n"}], }},]))pipeline := mongo.Pipeline{ {{Key: "$match", Value: bson.D{{Key: "genre", Value: "fiction"}}}}, {{Key: "$facet", Value: bson.D{ {Key: "page", Value: bson.A{ bson.D{{Key: "$sort", Value: bson.D{{Key: "price", Value: -1}, {Key: "title", Value: 1}}}}, bson.D{{Key: "$limit", Value: 3}}, }}, {Key: "total", Value: bson.A{bson.D{{Key: "$count", Value: "n"}}}}, }}},}cur, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}var rows []bson.Mif err := cur.All(ctx, &rows); err != nil { return err}let mut cursor = coll .aggregate(vec![ doc! { "$match": { "genre": "fiction" } }, doc! { "$facet": { "page": [ { "$sort": { "price": -1, "title": 1 } }, { "$limit": 3 }, ], "total": [{ "$count": "n" }], } }, ]) .await?;{ 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.
Where indexes apply
Section titled “Where indexes apply”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:
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: $facetsame sort+limit, find : keys=5 docs=5The 16 MiB output limit
Section titled “The 16 MiB output limit”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:
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 16793600bounded facet: 1000 rows + [{"n":200000}]$bucket groups into explicit ranges
Section titled “$bucket groups into explicit ranges”$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:
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 picks the boundaries for you
Section titled “$bucketAuto picks the boundaries for you”$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:
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] 28571every bucket equals a whole range of price values (no value split across buckets): true - so sizes cannot be forced to 50 000 eachOne request is not the same as less work
Section titled “One request is not the same as less work”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:
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.
Tips and gotchas
Section titled “Tips and gotchas”- Filter before the
$facetso every facet shares a narrowed, index-served input; do not repeat the$matchinside each facet. - The facet output is one document under the 16 MiB BSON limit: bound big facets with
$limit/$project/$group. $sort+$limitinside a facet cannot use an index; a paged list is often better asfind+ a count.$bucketboundaries must ascend; a value outside them needs adefaultor the stage fails.$bucketAutonever splits identical values, so sizes are uneven when values repeat.
Reading AI-generated code
Section titled “Reading AI-generated code”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.
db.sales.createIndex({ price: 1, _id: 1 }); // the index a paged listing sorted by price would useconst 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 msfind + estimatedCount: 200000 total, 10 rows in 4 msthe facet read 200000 documents; the find read 10Mental Model
Section titled “Mental Model”$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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
$facet | Several summaries, one request, one snapshot | Output capped at 16 MiB; sub-pipelines cannot use indexes |
| Separate queries | Each uses its best index (53 ms vs 85 ms locally) | More round trips; results from different moments |
$bucket | Boundaries match business bands | You maintain the boundaries; a default is required for strays |
$bucketAuto | No need to know the distribution | Uneven sizes; boundaries shift with the data |
Common Misconceptions
Section titled “Common Misconceptions”- “The 16 MiB limit is per facet.” - it applies to the whole output document.
- “
$facetcan never use an index.” - a$matchbefore it did (13 422 keys); only its inside cannot. - “One
$facetis cheaper than three queries.” - it saves round trips, not work (85 ms vs 53 ms here). - “
$bucketskips out-of-range values.” - it fails without adefault. - “
$bucketAutogives equal buckets.” - 57 144 / 57 143 / 57 142 / 28 571.
Real-world example
The manual’s
$facetpage states both limits proven here: “the$facetstage 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.