Facets in an online store seem simple until the first filter is selected.
In a catalog, they are part of the navigation. A selected color should not disappear from the list. Other colors should remain available: the user may want to switch to one or broaden the selection. Options with no matching products are more useful when shown as unavailable. Within a single facet, OR usually applies: red or blue. Across different facets, it is AND: brand, color, and size at the same time.
The difficult part begins after the first selection. Imagine a product catalog where the user has selected a brand, color, and size. The main result set must remain narrow and show only products matching all three conditions. But the filter panel follows different rules. It needs to preserve the selected color while also showing the colors the user can switch to under the same brand and size.
If every facet inherits all filters from the main query, it quickly collapses to the values already selected. If the filters for every facet have to be rebuilt manually, you end up with separate query branches for color, size, brand, availability, seller, and every other attribute.
Manticore Search 25.12.0 introduced facet_filter_mode, which moves this behavior into the facet API. A query can now describe how the store's filter panel should behave, without the application manually building nearly identical queries for every facet.
What e-commerce facets need
For a filter panel, answering "how many products have this value?" is not enough. It needs statuses that the interface can use directly:
- selected values remain visible and can be cleared quickly;
- other values in the same facet remain available because they broaden the filter through
IN (...); - values in other facets indicate whether selecting them would return at least one product;
- counts remain predictable: the interface knows whether a number refers to the current result set or a broader selection;
- search within long brand lists, categories, price ranges, SEO rules, and query analytics still matter; they are a separate part of search design.
facet_filter_mode handles the most common part of this problem: recalculating facets after the user has already selected several filters.
What changed
Facets now have three filter inheritance modes:
| Mode | Behavior |
|---|---|
strict | Applies all filters from the main query to the facet. This is the previous behavior and remains the default. |
auto | Applies every filter except filters on the facet itself and adds status: selected values get selected, while other values get available. |
max | Calculates buckets over a broad base result set and adds status to distinguish selected, available, and unavailable values. |
You can also control this manually:
ALL FILTERS- apply all filters to the facet;FILTERS color_id, size_id- apply only the listed filters;EXCLUDE FILTERS color_id- apply every filter except those listed;ZEROES- starting with Manticore Search 27.3.0, preserve buckets from the broadmaxscope in SQLmaxmode even when theircount(*)in the current facet is0.
The same options are available in the JSON API through facet_filter_mode, mode, filters, exclude_filters, and zeroes: true.
In short, strict answers "what is in the current result set?", auto answers "what happens if this particular filter changes?", and max shows a broad list of buckets with a status field: selected, available, or unavailable.
Minimal example
Consider a small catalog with a brand, color, size, and SKU:
CREATE TABLE products(
title text,
brand_id int,
color_id int,
color_name string,
size_id int,
size_name string,
sku string
);
INSERT INTO products(id,title,brand_id,color_id,color_name,size_id,size_name,sku) VALUES
(1,'p1',7,1,'red',10,'small','sku1'),
(2,'p2',7,1,'red',20,'large','sku2'),
(3,'p3',7,2,'blue',10,'small','sku3'),
(4,'p4',7,3,'green',30,'xlarge','sku4'),
(5,'p5',8,1,'red',10,'small','sku5'),
(6,'p6',8,4,'black',20,'large','sku6'),
(7,'p7',9,5,'white',10,'small','sku7');
The user selected:
brand_id=7 AND color_id=1 AND size_id=10
Under these conditions, the result set contains one product: p1. Let's see what happens to the facets.
strict: the previous behavior
Without additional options, Manticore Search uses strict: every facet receives all filters from the main query.
SELECT count(*)
FROM products
WHERE brand_id=7 AND color_id=1 AND size_id=10
LIMIT 0
FACET color_id ORDER BY color_id ASC
FACET size_id ORDER BY size_id ASC;
Response:
+----------+----------+
| color_id | count(*) |
+----------+----------+
| 1 | 1 |
+----------+----------+
+---------+----------+
| size_id | count(*) |
+---------+----------+
| 10 | 1 |
+---------+----------+
This is an accurate SQL response, but it is often too narrow for an online store. The user sees only the already selected color_id=1 and size_id=10. It looks as if there are no other options, even though the data contains a product with the same brand and size but a different color (color_id=2), and another with the same brand and color but a different size (size_id=20).
auto: a facet ignores its own filter
The auto mode keeps every filter except the filter on the facet currently being calculated.
SELECT count(*)
FROM products
WHERE brand_id=7 AND color_id=1 AND size_id=10
LIMIT 0
OPTION facet_filter_mode='auto'
FACET color_id ORDER BY color_id ASC
FACET size_id ORDER BY size_id ASC;
Response:
+----------+----------+-----------+
| color_id | count(*) | status |
+----------+----------+-----------+
| 1 | 1 | selected |
| 2 | 1 | available |
+----------+----------+-----------+
+---------+----------+-----------+
| size_id | count(*) | status |
+---------+----------+-----------+
| 10 | 1 | selected |
| 20 | 1 | available |
+---------+----------+-----------+
Here is what happened:
FACET color_idappliedbrand_id=7 AND size_id=10, but notcolor_id=1;FACET size_idappliedbrand_id=7 AND color_id=1, but notsize_id=10.
This gives the interface alternative values without a separate query for every facet. Selected buckets are marked selected, while other values in the same facet are marked available: they can be added to the current filter as a broader selection through IN (...).
max: a broad bucket list with statuses
auto shows only values from the current filter scope for a particular facet. max goes further: it calculates buckets over the base result set and marks their status separately for the interface.
SELECT count(*)
FROM products
WHERE brand_id=7 AND color_id=1 AND size_id=10
LIMIT 0
OPTION facet_filter_mode='max'
FACET color_id ORDER BY color_id ASC
FACET size_id ORDER BY size_id ASC;
Response:
+----------+----------+-----------+
| color_id | count(*) | status |
+----------+----------+-----------+
| 1 | 3 | selected |
| 2 | 1 | available |
| 3 | 1 | available |
| 4 | 1 | available |
| 5 | 1 | available |
+----------+----------+-----------+
+---------+----------+-----------+
| size_id | count(*) | status |
+---------+----------+-----------+
| 10 | 4 | selected |
| 20 | 2 | available |
| 30 | 1 | available |
+---------+----------+-----------+
status comes directly from Manticore Search:
selected- the value is already present in the filter on this facet;available- the value can be selected; for another value in the same facet, this broadens the filter throughIN (...);unavailable- the value exists in the facet's broad result set, but selecting it would return no documents under the current filters.
In this example, color_id=1 occurs in three products across the catalog, so its count in max is 3. It is marked selected because it already participates in the filter. The other colors are marked available: if the user selects one, the color filter becomes broader, for example color_id IN (1,2). Unavailable values appear when a bucket in the broad result set cannot return documents under the current filters; the sku example below demonstrates this case.
Manually setting the filter scope
The global facet_filter_mode covers most common cases, but sometimes different facets need different behavior. For example, color can remain strictly constrained by all filters, size can use max, SKU can be calculated using only color and size, and brand can be calculated without the color filter.
SELECT count(*)
FROM products
WHERE brand_id=7 AND color_id=1 AND size_id=10
LIMIT 0
OPTION facet_filter_mode='max'
FACET color_id ALL FILTERS ORDER BY color_id ASC
FACET size_id ORDER BY size_id ASC
FACET sku FILTERS color_id, size_id ORDER BY sku ASC
FACET brand_id EXCLUDE FILTERS color_id ORDER BY brand_id ASC;
Response:
+----------+----------+-----------+
| color_id | count(*) | status |
+----------+----------+-----------+
| 1 | 1 | selected |
+----------+----------+-----------+
+---------+----------+-----------+
| size_id | count(*) | status |
+---------+----------+-----------+
| 10 | 4 | selected |
| 20 | 2 | available |
| 30 | 1 | available |
+---------+----------+-----------+
+------+----------+-------------+
| sku | count(*) | status |
+------+----------+-------------+
| sku1 | 1 | available |
| sku5 | 1 | unavailable |
+------+----------+-------------+
+----------+----------+----------+
| brand_id | count(*) | status |
+----------+----------+----------+
| 7 | 2 | selected |
+----------+----------+----------+
How to read this query:
FACET color_id ALL FILTERSapplies every filter and returns only the selected color;FACET size_idinherits the query-levelmaxmode;FACET sku FILTERS color_id, size_idapplies only the color and size filters;FACET brand_id EXCLUDE FILTERS color_idapplies every filter except color.
This mode is useful when a filter panel contains both regular and technical facets, and some values need to be calculated according to special rules.
ZEROES: zero-count buckets in max
ZEROES is useful when counts need to remain strict but the list of values needs to stay broad. It works with max, either through OPTION facet_filter_mode='max' or through MODE max on an individual facet.
Without ZEROES, a facet with ALL FILTERS shows only the bucket that passes every filter:
SELECT count(*)
FROM products
WHERE brand_id=7 AND color_id=1 AND size_id=10
LIMIT 0
OPTION facet_filter_mode='max'
FACET size_id ALL FILTERS ORDER BY size_id ASC;
Response:
+---------+----------+----------+
| size_id | count(*) | status |
+---------+----------+----------+
| 10 | 1 | selected |
+---------+----------+----------+
With ZEROES, Manticore Search keeps the same visible counts but returns the remaining buckets from the broad max scope with a count of zero:
SELECT count(*)
FROM products
WHERE brand_id=7 AND color_id=1 AND size_id=10
LIMIT 0
OPTION facet_filter_mode='max'
FACET size_id ALL FILTERS ZEROES ORDER BY size_id ASC;
Response:
+---------+----------+-----------+
| size_id | count(*) | status |
+---------+----------+-----------+
| 10 | 1 | selected |
| 20 | 0 | available |
| 30 | 0 | available |
+---------+----------+-----------+
The interface can then show large and xlarge alongside the selected small: the number refers to the current strict result set, while status shows that these values can still be selected to broaden the filter.
The same query through the JSON API
SQL is shorter here and shows the mechanics more clearly, but the JSON API supports the same approach:
POST /search
{
"table": "products",
"limit": 0,
"query": {
"bool": {
"must": [
{ "equals": { "brand_id": 7 } },
{ "equals": { "color_id": 1 } },
{ "equals": { "size_id": 10 } }
]
}
},
"facet_filter_mode": "max",
"aggs": {
"colors": {
"terms": { "field": "color_id", "size": 10 },
"sort": [ { "color_id": { "order": "asc" } } ]
},
"sizes": {
"terms": { "field": "size_id", "size": 10 },
"sort": [ { "size_id": { "order": "asc" } } ]
}
}
}
Response:
{
"took": 0,
"timed_out": false,
"hits": {
"total": 1,
"total_relation": "eq",
"hits": []
},
"aggregations": {
"colors": {
"buckets": [
{ "key": 1, "doc_count": 3, "status": "selected" },
{ "key": 2, "doc_count": 1, "status": "available" },
{ "key": 3, "doc_count": 1, "status": "available" },
{ "key": 4, "doc_count": 1, "status": "available" },
{ "key": 5, "doc_count": 1, "status": "available" }
]
},
"sizes": {
"buckets": [
{ "key": 10, "doc_count": 4, "status": "selected" },
{ "key": 20, "doc_count": 2, "status": "available" },
{ "key": 30, "doc_count": 1, "status": "available" }
]
}
}
}
In JSON aggregations, mode, filters, exclude_filters, and zeroes can also be set for each individual aggregation.
How this differs from Meilisearch, Elasticsearch, and OpenSearch
We tested the same scenario in Manticore Search, Meilisearch, Elasticsearch, and OpenSearch: the brand_id=7, color_id=1, and size_id=10 filters are active; the result set remains strict, while the facets need to show options that appear when their own filter is excluded.
| Engine | Excludes its own filter in one query | Native bucket status | What remains for the application |
|---|---|---|---|
| Manticore Search | Yes, through auto/max | Yes, in auto/max; unavailable only in max | Rendering the interface, SEO, analytics, and custom rules. |
| Meilisearch | Not in this form | No | Run additional queries and assemble facets in the application. |
| OpenSearch | Yes, but manually through global + filter aggregations | No | Duplicate filter branches and calculate status in the application. |
| Elasticsearch | Yes, but manually through global + filter aggregations | No | The same: maintain separate aggregation branches and client-side logic. |
Basic facets are easy to use in Meilisearch, but a query such as:
{
"filter": ["brand_id = 7", "color_id = 1", "size_id = 10"],
"facets": ["color_id", "size_id"]
}
returns counts for the already filtered result set. In our dataset, that means only color_id=1 and size_id=10. The alternative color_id=2 and size_id=20 values do not appear in this response. If the interface needs them, the application must make additional queries and merge the results.
In Elasticsearch and OpenSearch, similar behavior can be built in one query, but each facet needs its own explicit aggregation branch. For color, you keep brand_id and size_id; for size, you keep brand_id and color_id; and so on. This works, but the query grows quickly, and bucket statuses still have to be calculated in the application.
The main difference is where this logic lives. In Manticore Search, it is configured directly in the facet API; in other systems, it is usually assembled from several similar filter trees.
Performance and limitations
strict remains the default and preserves the previous behavior. If you only need facets within the current result set, nothing needs to change.
auto is usually the better fit for e-commerce filters: it shows alternative values within each facet, marks selected values as selected, and marks other values as available.
Use max when the interface needs value lists broader than the current result set and needs to show unavailable options. This mode costs more: Manticore Search calculates buckets over a broad scope and then determines their status separately. This is worth considering with large datasets and many facets.
There are also some limitations:
- local filter rewriting for a facet supports only attribute filters combined with
AND; - complex
AND/ORtrees are not rewritten automatically for individual facets; - selected values are currently matched reliably only for explicit value filters such as
=andIN; - statuses are not yet calculated automatically for ranges such as prices.
For prices, discounts, and ratings, it is better to define separate ranges explicitly. For example, keep a numeric field for sorting and sliders, and add price_band or discount_band for the facet. This lets the interface show clear statuses for predefined ranges.
SEO for faceted URLs, A/B tests, merchandising rules, and query analytics remain the responsibility of the application or platform around search.
In practice, start with auto when you need alternative values, and move to max when the interface needs a broad bucket list with selected, available, and unavailable statuses.
Further reading
For an introduction to faceted search in Manticore Search, start with the earlier Faceted search
article. It covers basic FACET queries, sorting, limits, and facets over expressions.
See the FACET documentation
for details.
For an interactive introduction to facets, take the Manticore Faceting course.
