Dynamic Facet Counts and Post-Filtering
A user checks “Brand: Acme” and every other brand in the sidebar drops to zero — even though the catalog still has plenty of Globex products. The specific failure is that the active filter was applied in query context, so the brand aggregation now sees only Acme documents. This guide, under the faceted navigation and filtering guide and the wider search frontend UX patterns area, shows the three mechanisms — post_filter, global aggregations, and per-facet filtered aggregations — that keep each facet’s counts honest while the others narrow correctly.
Prerequisites
- Elasticsearch 8.x or OpenSearch 2.x at
localhost:9200withkeywordfacet fields. - A working faceted request from building faceted filters with aggregations.
- Understanding of query vs filter context per Elasticsearch fundamentals for engineers.
Diagnosis / Context
The desired behavior is asymmetric. When the user selects a brand, the color facet should narrow to colors available within Acme, but the brand facet should still show every brand’s count so the user can switch brands without first un-checking. A naive bool.filter cannot express this, because anything in the query narrows all aggregations uniformly.
You can see the bug directly. Put the brand filter in the query, and the brand aggregation collapses:
{
"query": { "bool": { "filter": [{ "term": { "brand": "acme" } }] } },
"aggs": { "by_brand": { "terms": { "field": "brand" } } }
}
The response shows the problem — only one brand survives:
{
"aggregations": {
"by_brand": { "buckets": [ { "key": "acme", "doc_count": 42 } ] }
}
}
Globex, with its 18 matching products, has vanished from the sidebar. The user is now trapped: switching brands requires un-checking first.
Solution Steps
1. Move the active facet’s filter to post_filter
post_filter runs after aggregations, so the hits returned are filtered but the aggregations still see the full result set. This keeps the brand facet showing all brands while the hit list shows only Acme.
{
"query": { "bool": { "must": [{ "match_all": {} }] } },
"aggs": {
"by_brand": { "terms": { "field": "brand", "size": 20 } }
},
"post_filter": { "term": { "brand": "acme" } }
}
Now by_brand reports every brand’s count, while hits contains only Acme documents — the brand facet stays switchable.
2. Use a global aggregation when other facets must stay narrowed
post_filter is all-or-nothing: it un-narrows every aggregation. When you want the color facet narrowed to Acme but the brand facet to span everything, keep the brand filter in the query and wrap the brand aggregation in a global bucket. A global agg ignores the query entirely and counts across the whole index.
{
"query": { "bool": { "filter": [{ "term": { "brand": "acme" } }] } },
"aggs": {
"by_color": { "terms": { "field": "color", "size": 20 } },
"all_brands": {
"global": {},
"aggs": { "by_brand": { "terms": { "field": "brand", "size": 20 } } }
}
}
}
Here by_color correctly narrows to Acme’s colors, while all_brands.by_brand ignores the filter and shows every brand. The catch: global ignores the entire query, including the user’s text search, so brand counts no longer respect “running shoes”.
3. Per-facet filtered aggs — the production-correct pattern
The robust approach computes each facet’s counts against every active filter except its own. Each facet gets a filter aggregation carrying the other facets’ selections. Selecting a color narrows the brand counts, but the color facet itself still shows all colors available under the current brand.
{
"query": {
"bool": { "must": [{ "match": { "title": "running shoes" } }] }
},
"aggs": {
"brand_facet": {
"filter": { "bool": { "filter": [{ "terms": { "color": ["red"] } }] } },
"aggs": { "values": { "terms": { "field": "brand", "size": 20 } } }
},
"color_facet": {
"filter": { "bool": { "filter": [{ "term": { "brand": "acme" } }] } },
"aggs": { "values": { "terms": { "field": "color", "size": 20 } } }
}
}
}
Each facet’s filter sub-agg includes the other facet’s active selections but omits its own, so its counts reflect what would happen if the user added a value — without pre-collapsing its own options. Build these dynamically: for facet N, the filter is the intersection of all selections where the facet name is not N.
// Omit a facet's own selection from its counting filter
function facetFilters(selections, exclude) {
return Object.entries(selections)
.filter(([name, vals]) => name !== exclude && vals.length)
.map(([name, vals]) => ({ terms: { [name]: vals } }));
}
Verification
Confirm that with an active color filter, the brand facet still lists multiple brands rather than collapsing.
curl -s -X POST "localhost:9200/products/_search" -H 'Content-Type: application/json' -d '{
"size": 0,
"query": { "bool": { "must": [{ "match": { "title": "running shoes" } }] } },
"aggs": {
"brand_facet": {
"filter": { "bool": { "filter": [{ "terms": { "color": ["red"] } }] } },
"aggs": { "values": { "terms": { "field": "brand", "size": 20 } } }
}
}
}' | jq '.aggregations.brand_facet.values.buckets'
Expected output — more than one brand survives, each counted within the red-color constraint:
[
{ "key": "acme", "doc_count": 22 },
{ "key": "globex", "doc_count": 9 }
]
Common Pitfalls
post_filter and global both ignore the text query
A global aggregation drops the query entirely, so facet counts stop respecting the user’s search terms and show catalog-wide totals. When the text query must still apply, prefer per-facet filter aggregations that re-state the match clause, rather than reaching for global.
Mixing post_filter with per-facet aggs double-applies filters
If a value lives in both post_filter and a per-facet filter agg, it is applied twice and the counts no longer mean what the UI claims. Pick one strategy per facet: post_filter for a single active facet, or per-facet filtered aggs for true multi-facet correctness — never layer them on the same field.
Forgetting to exclude a facet's own selection from its filter
If facet N’s counting filter includes facet N’s own selected values, the facet collapses to only the chosen values and the user cannot see alternatives. The exclusion in facetFilters(selections, exclude) is load-bearing — drop it and you reintroduce the original zeroing bug this guide exists to fix.
The reason this is worth getting exactly right is that facet counts are a promise. A user reads “Acme (14)” as a commitment that clicking it yields fourteen results, and any deviation — from the wrong semantics, from a stale count, or from an inconsistent request — is experienced as the interface lying rather than as a subtle aggregation detail.
Two count semantics, and users expect different ones per facet
The confusing part of facet counts is that the “correct” answer differs by facet type within the same page. For a single-select facet — a category, where choosing one replaces the other — users expect counts computed without that facet’s own filter applied, so they can see what switching to a different category would yield. For a multi-select facet — brands, where choices accumulate — users expect counts computed with all other filters applied, showing what adding this value would return.
Implementing both means the aggregation for each facet excludes its own filter but includes every other one. That is exactly what a post-filter arrangement provides: the main query carries the shared filters, each facet’s aggregation is scoped to exclude its own, and the result set is filtered afterwards.
Getting the semantics right also removes a class of support ticket that is otherwise hard to answer, because the user’s description — “the number was wrong” — is accurate and the code is doing exactly what it was written to do.
Counts and results must come from one request
A subtle failure appears when facet counts and the result list are fetched separately: the two responses describe slightly different corpus states, so a facet promises fourteen results and the list shows thirteen. On a fast-changing catalog this happens often enough for users to notice, and it reads as a counting bug rather than a timing one.
Computing both from a single request eliminates it entirely, because both derive from the same point-in-time view of the index. That is an additional reason — beyond the cost saving — to prefer one request carrying aggregations over separate calls, and it is the kind of consistency issue that is very hard to diagnose after the fact.
Zero-count values are information, not noise
The instinct is to hide facet values whose count is zero, and for a multi-select facet that is right — an option that would return nothing is a dead end. For a single-select facet it is often wrong: hiding a category because the current brand filter excludes it removes the user’s ability to see that the category exists at all, and the resulting sidebar appears to shrink mysteriously as filters are applied.
The better pattern for single-select facets is to show zero-count values in a disabled state, so the structure of the catalog stays visible while the unavailable choices are clearly marked. It costs a CSS class and prevents the common complaint that “the filters keep disappearing”.
The cost of all this correctness is modest and worth paying: post-filtered aggregations add a few milliseconds over naive ones, and they remove the most common source of user distrust in a faceted interface.
Related
- Faceted navigation and filtering — the parent guide on aggregation types and multi-select semantics.
- Building faceted filters with aggregations — the end-to-end request and UI wiring these fixes plug into.
- Elasticsearch fundamentals for engineers — the query/filter/post-filter execution order that makes this work.