
Using $facet in MongoDB to Build Multi-Faceted Search Results
Look at any decent product search page. Next to the results there's a sidebar: brands with counts beside them, price bands, ratings, a "showing 1–24 of 312" line at the top. Every one of those numbers is a separate question about the same set of matching products. Answer them with separate queries and your search endpoint makes five round trips, each re-running the same filter, and the counts can drift out of sync with the results if data changes between queries.
MongoDB's $facet stage solves this neatly. It takes the documents flowing through a pipeline and feeds the same input into several independent sub-pipelines at once, returning all of their outputs in a single document. One query gives you the page of results, the total count, and every facet's counts, all computed from the same snapshot of matching data.
This guide covers how $facet works, building a complete faceted search endpoint step by step, the companion stages $bucket, $bucketAuto, and $sortByCount, performance rules that matter a lot here, the "selected facet" problem, and when Atlas Search facets are the better tool.
How $facet Works
$facet takes an object where each key is an output field and each value is a pipeline:
db.products.aggregate([
{ $match: { category: "lighting" } },
{
$facet: {
results: [{ $sort: { price: 1 } }, { $limit: 3 }],
total: [{ $count: "count" }],
byBrand: [{ $group: { _id: "$brand", count: { $sum: 1 } } }],
},
},
]);
The output is always a single document, with each facet's result as an array:
[
{
results: [ { ... }, { ... }, { ... } ],
total: [ { count: 42 } ],
byBrand: [ { _id: "Lumen", count: 18 }, { _id: "Halo", count: 24 } ]
}
]
A few rules shape how you use it:
- Every sub-pipeline receives the same input documents: whatever reached the
$facetstage. - Sub-pipelines run independently. One facet's output isn't visible to another.
- Sub-pipelines can't contain
$facet,$out,$merge,$geoNear,$indexStats,$collStats,$search, or$searchMeta. - The entire output document, all facets together, must fit within the 16 MB BSON document limit.
Sample Data
The rest of the examples use a small products collection:
db.products.insertMany([
{
name: "Arc Floor Lamp",
brand: "Lumen",
category: "lighting",
price: 189,
rating: 4.6,
colors: ["black", "brass"],
inStock: true,
},
{
name: "Mini Desk Lamp",
brand: "Halo",
category: "lighting",
price: 39,
rating: 4.1,
colors: ["white"],
inStock: true,
},
{
name: "Globe Pendant",
brand: "Lumen",
category: "lighting",
price: 129,
rating: 4.8,
colors: ["brass"],
inStock: false,
},
{
name: "Clamp Task Light",
brand: "Halo",
category: "lighting",
price: 59,
rating: 3.9,
colors: ["black", "white"],
inStock: true,
},
{
name: "Linen Shade Lamp",
brand: "Nordlys",
category: "lighting",
price: 89,
rating: 4.4,
colors: ["white", "grey"],
inStock: true,
},
{
name: "Oak Wall Sconce",
brand: "Nordlys",
category: "lighting",
price: 74,
rating: 4.2,
colors: ["brass"],
inStock: true,
},
{
name: "Woven Rug",
brand: "Nordlys",
category: "textiles",
price: 220,
rating: 4.5,
colors: ["grey"],
inStock: true,
},
]);
db.products.createIndex({ category: 1, price: 1 });
Building a Faceted Search, Step by Step
Step 1: Match First
Everything the facets count should be filtered before $facet. This $match is the only part of the pipeline that can use an index, so it carries the performance weight:
const match = { category: "lighting", inStock: true };
Step 2: The Results Facet
The page of results needs sorting, pagination, and a projection so you don't ship full documents to the client:
results: [
{ $sort: { rating: -1, _id: 1 } },
{ $skip: 0 },
{ $limit: 24 },
{ $project: { name: 1, brand: 1, price: 1, rating: 1 } },
],
The _id in the sort is a tiebreaker. Without it, products with the same rating can appear in a different order on each request, so an item might show up on two pages or none.
Step 3: The Total Count
total: [{ $count: "count" }],
$count returns an empty array when no documents match (not [{ count: 0 }]), so handle that in your application.
Step 4: Term Facets with $sortByCount
For brand-style facets (distinct values with counts, most common first), $sortByCount is a shortcut for $group plus $sort:
brands: [{ $sortByCount: "$brand" }],
For array fields like colors, unwind first, so each color is counted per product:
colors: [{ $unwind: "$colors" }, { $sortByCount: "$colors" }],
Step 5: Range Facets with $bucket
Price bands are a range facet. $bucket groups documents into ranges you define:
priceRanges: [
{
$bucket: {
groupBy: "$price",
boundaries: [0, 50, 100, 200, 500],
default: "500+",
output: { count: { $sum: 1 } },
},
},
],
Each bucket includes its lower boundary and excludes its upper one, so [0, 50) holds 39 but not 50. The default bucket collects anything outside the boundaries (and documents where the field is missing); without it, those documents cause an error. Boundaries must be sorted ascending and of the same type.
Step 6: Put It Together
db.products.aggregate([
{ $match: { category: "lighting", inStock: true } },
{
$facet: {
results: [
{ $sort: { rating: -1, _id: 1 } },
{ $skip: 0 },
{ $limit: 24 },
{ $project: { name: 1, brand: 1, price: 1, rating: 1 } },
],
total: [{ $count: "count" }],
brands: [{ $sortByCount: "$brand" }],
colors: [{ $unwind: "$colors" }, { $sortByCount: "$colors" }],
priceRanges: [
{
$bucket: {
groupBy: "$price",
boundaries: [0, 50, 100, 200, 500],
default: "500+",
output: { count: { $sum: 1 } },
},
},
],
},
},
{
$project: {
results: 1,
brands: 1,
colors: 1,
priceRanges: 1,
total: { $ifNull: [{ $first: "$total.count" }, 0] },
},
},
]);
[
{
results: [
{
_id: ObjectId("..."),
name: "Arc Floor Lamp",
brand: "Lumen",
price: 189,
rating: 4.6,
},
{
_id: ObjectId("..."),
name: "Linen Shade Lamp",
brand: "Nordlys",
price: 89,
rating: 4.4,
},
{
_id: ObjectId("..."),
name: "Oak Wall Sconce",
brand: "Nordlys",
price: 74,
rating: 4.2,
},
{
_id: ObjectId("..."),
name: "Mini Desk Lamp",
brand: "Halo",
price: 39,
rating: 4.1,
},
{
_id: ObjectId("..."),
name: "Clamp Task Light",
brand: "Halo",
price: 59,
rating: 3.9,
},
],
brands: [
{ _id: "Halo", count: 2 },
{ _id: "Nordlys", count: 2 },
{ _id: "Lumen", count: 1 },
],
colors: [
{ _id: "white", count: 3 },
{ _id: "black", count: 2 },
{ _id: "brass", count: 2 },
{ _id: "grey", count: 1 },
],
priceRanges: [
{ _id: 0, count: 1 },
{ _id: 50, count: 3 },
{ _id: 100, count: 1 },
],
total: 5,
},
];
The final $project flattens total into a plain number, turning an empty array into 0. Each bucket's _id is its lower boundary, so { _id: 50, count: 3 } means "3 products from 50 up to (but not including) 100". Empty buckets are omitted. Ties in $sortByCount (Halo and Nordlys both have 2) come back in no guaranteed order, so if the UI needs stable ordering, use $group plus $sort: { count: -1, _id: 1 } instead.
$bucketAuto for Automatic Ranges
When you don't know good boundaries in advance, $bucketAuto picks them to spread documents evenly across a requested number of buckets:
db.products.aggregate([
{ $match: { category: "lighting" } },
{
$bucketAuto: {
groupBy: "$price",
buckets: 3,
output: { count: { $sum: 1 }, avgRating: { $avg: "$rating" } },
},
},
]);
[
{ _id: { min: 39, max: 74 }, count: 2, avgRating: 4 },
{ _id: { min: 74, max: 129 }, count: 2, avgRating: 4.3 },
{ _id: { min: 129, max: 189 }, count: 2, avgRating: 4.7 },
];
The exact output depends on your data (and floating-point averages may show more decimals). The granularity option (such as "R5", "1-2-5", or "POWERSOF2") rounds boundaries to "nice" numbers, which looks better in a UI than 74 or 129. For most storefronts, fixed $bucket boundaries are more predictable, but $bucketAuto is great for exploratory dashboards.
Building the Pipeline from Request Parameters
In a real API, the filters come from the query string. Here's an Express handler using the Node.js driver that builds the pipeline dynamically and whitelists what users can filter on:
import express from "express";
import { MongoClient } from "mongodb";
const client = new MongoClient(process.env.MONGODB_URI);
const products = client.db("shop").collection("products");
const app = express();
const SORTS = {
rating: { rating: -1, _id: 1 },
priceAsc: { price: 1, _id: 1 },
priceDesc: { price: -1, _id: 1 },
};
app.get("/search", async (req, res) => {
const { category, brand, minPrice, maxPrice, sort = "rating" } = req.query;
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
const pageSize = 24;
const sortSpec = Object.hasOwn(SORTS, sort) ? SORTS[sort] : SORTS.rating;
const match = { inStock: true };
if (typeof category === "string") match.category = category;
if (typeof brand === "string") match.brand = brand;
if (minPrice || maxPrice) {
match.price = {};
if (minPrice) match.price.$gte = Number(minPrice);
if (maxPrice) match.price.$lt = Number(maxPrice);
}
const [data] = await products
.aggregate([
{ $match: match },
{
$facet: {
results: [
{ $sort: sortSpec },
{ $skip: (page - 1) * pageSize },
{ $limit: pageSize },
{ $project: { name: 1, brand: 1, price: 1, rating: 1 } },
],
total: [{ $count: "count" }],
brands: [{ $sortByCount: "$brand" }],
priceRanges: [
{
$bucket: {
groupBy: "$price",
boundaries: [0, 50, 100, 200, 500],
default: "500+",
output: { count: { $sum: 1 } },
},
},
],
},
},
])
.toArray();
res.json({
page,
total: data.total[0]?.count ?? 0,
results: data.results,
facets: { brands: data.brands, priceRanges: data.priceRanges },
});
});
The typeof ... === "string" checks matter. Query string parsers can turn ?brand[$ne]=x into an object, and passing that straight into $match is a NoSQL injection hole. The guide to preventing NoSQL injection covers this in depth.
The Selected-Facet Problem
There's a subtle UX issue with the pipeline above. When a user clicks "Brand: Halo", the brand filter goes into $match, so the brand facet now shows only Halo. The user can't see how many products the other brands have, or switch to another brand, without un-clicking first.
Most good search UIs use disjunctive faceting: each facet's counts ignore that facet's own filter but respect all the others. In $facet, you get there by keeping the shared filters in the top-level $match and applying each facet's own selection only in the sub-pipelines that need it:
const base = { category: "lighting", inStock: true };
const brandFilter = { brand: "Halo" };
const priceFilter = { price: { $gte: 50, $lt: 100 } };
db.products.aggregate([
{ $match: base },
{
$facet: {
// results respect every filter
results: [
{ $match: { ...brandFilter, ...priceFilter } },
{ $sort: { rating: -1, _id: 1 } },
{ $limit: 24 },
],
total: [
{ $match: { ...brandFilter, ...priceFilter } },
{ $count: "count" },
],
// brand counts ignore the brand filter
brands: [{ $match: priceFilter }, { $sortByCount: "$brand" }],
// price counts ignore the price filter
priceRanges: [
{ $match: brandFilter },
{
$bucket: {
groupBy: "$price",
boundaries: [0, 50, 100, 200, 500],
default: "500+",
},
},
],
},
},
]);
Now the brand list still shows Nordlys and Lumen with their counts (within the selected price band), so the user can switch brands with one click. The cost is that the top-level $match is less selective, so more documents flow into $facet. Keep the base filter as tight as your UI allows.
Performance: What to Know
$facet is convenient, but it has sharp edges at scale.
Only the stage before $facet can use indexes. Sub-pipelines operate on the in-memory stream passed to them. The $match and $sort inside a facet can't use an index. That's why the top-level $match needs to be selective and index-backed.
Every facet processes the full input. If 200,000 products match, every sub-pipeline touches all 200,000. A results facet with $sort then sorts 200,000 documents in memory to return 24. For large result sets, it's often faster to run the results query separately (with an indexed sort and range-based pagination; see the guide to pagination in MongoDB) and use $facet only for the counts, running both queries in parallel with Promise.all.
Watch the 16 MB limit. A facet that returns all matching documents (a missing $limit, or a term facet over a high-cardinality field like SKU) can blow the output document past 16 MB and fail the entire query. Always limit results, and cap term facets:
brands: [{ $sortByCount: "$brand" }, { $limit: 20 }],
Project early when documents are large. If products carry long descriptions or image arrays, a $project before $facet that keeps only the fields any facet needs reduces the memory each sub-pipeline uses.
Measure. Run the pipeline with explain("executionStats") and check that the first stage is an IXSCAN and that nReturned from the $match is in the range you expect.
Atlas Search Facets
If you run on MongoDB Atlas and your search needs full-text relevance, typo tolerance, or very large collections, Atlas Search has its own faceting built into the search index. The $searchMeta stage with a facet collector computes counts from the index itself, without streaming every matching document through the aggregation pipeline:
db.products.aggregate([
{
$searchMeta: {
index: "products",
facet: {
operator: { text: { query: "lamp", path: "name" } },
facets: {
brands: { type: "string", path: "brand" },
prices: {
type: "number",
path: "price",
boundaries: [0, 50, 100, 200, 500],
},
},
},
},
},
]);
For this to work, the search index must map brand and price with facet-compatible field types (check the Atlas Search documentation for the current mapping names, as they've evolved across versions). As a rule of thumb: use $facet for moderate result sets and structured filters on any MongoDB deployment, and move to Atlas Search facets once you need text relevance or faceting over large result sets. The post on Atlas Search covers setup.
Common Pitfalls
Forgetting the top-level $match. Without it, every facet processes the whole collection. Put all shared filters before $facet, backed by an index.
Leaving facets unbounded. A results facet without $limit or a term facet over a high-cardinality field can exceed 16 MB and fail the query. Limit everything.
Assuming $count returns zero. When nothing matches, the count facet is an empty array. Use $ifNull with $first, or handle it in code.
Sorting without a tiebreaker. Sort on a unique field last (usually _id) so pagination is stable across requests.
Missing the default bucket. Documents outside $bucket boundaries, or without the field, make the stage throw unless you provide default.
Filtering facets by their own selection. If a user selects a brand and the brand facet collapses to one entry, move that filter into the other facets' sub-pipelines for disjunctive faceting.
Conclusion
$facet lets one aggregation return everything a search page needs: the current page of results, the total count, and counts for every filter, all computed from the same set of matching documents in a single round trip. Combine it with $sortByCount for term facets and $bucket or $bucketAuto for ranges, put a selective, indexed $match in front, and keep every facet bounded. When result sets grow large or you need text relevance, split the results query out or move to Atlas Search facets.
Take the search or listing endpoint in your app that currently makes separate queries for results and counts. Combine them into one $facet pipeline, compare the response time, and check that the counts now always agree with the results.


