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

  1. Elasticsearch 8.x or OpenSearch 2.x at localhost:9200 with keyword facet fields.
  2. A working faceted request from building faceted filters with aggregations.
  3. 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.

Count semantics expected for single-select and multi-select facets Single-select facets show counts excluding their own filter so users can switch, while multi-select facets show counts including other filters so users can narrow. single-select — category exclude this facet's own filter "what if I switched to that one?" multi-select — brand include every other filter "what if I added that one?" both rules reduce to: every facet's aggregation excludes its own filter and includes the rest which is precisely what post-filtering implements
The two expectations look contradictory and collapse into one rule. Implementing that rule uniformly is simpler than special-casing per facet type.

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.

Separate requests can disagree about the corpus Two requests see different index states so counts and results disagree, while one request derives both from the same view. two requests facet says 14, list shows 13 one request both from the same index view Reported as a counting bug, caused by timing — and effectively impossible to reproduce on demand.
Consistency, not just cost, argues for one request. Off-by-one facet counts on a busy catalog are usually this, not an aggregation error.

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

Handling facet values with a zero count Hide zero-count values on multi-select facets, and show them disabled on single-select facets so the catalog structure stays visible. multi-select — hide them an option returning nothing is a dead end single-select — disable them keeps the catalog's structure visible Hiding values on a single-select facet produces the "my filters keep disappearing" complaint.
Same zero count, two correct treatments. The distinction follows from whether the value is an alternative or an addition.

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.