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.
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.