Faceted Navigation & Filtering with Aggregations

Faceted navigation is the contract between your inverted index and the sidebar a user clicks. Get it wrong and selecting “Brand: Acme” silently zeroes out every other brand count, or a high-cardinality color facet doubles your query latency. This guide, part of the search frontend UX patterns area, resolves the central engineering decision: how to compute facet buckets and apply filters so that counts stay truthful, multi-select works, and the aggregation phase stays cheap under load. We work directly against Elasticsearch and OpenSearch aggs, because the semantics you choose at the request-body level dictate everything the frontend can render.

Prerequisites

  • localhost:9200
  • keyword, integer/float, date, or nested — see schema design and index mapping for why text fields cannot be aggregated directly
  • Elasticsearch fundamentals for engineers
  • eager_global_ordinals understood for high-cardinality keyword facets
  • curl and jq for verifying responses

Concept Deep-Dive

A facet is two things at once: a filter the user can apply (a term clause in filter context) and an aggregation that counts how many documents fall into each bucket. The engine evaluates them in distinct phases. The query selects the matching hit set. Then aggs run over that hit set — or, when you use a post_filter, over a deliberately wider set — to produce buckets. Understanding which set each aggregation sees is the whole game.

The canonical request carries three concerns in one body: the query that defines relevance, the aggregations that produce facet buckets, and (optionally) a post_filter that narrows the returned hits after aggregations have been computed. The diagram traces that flow from request to rendered UI.

Faceted query to UI data flow A search query produces a filtered hit set; aggregations over that set produce facet buckets that render as the sidebar UI. Search request query + aggs Filtered hits matched doc set Aggregation buckets brand:42 color:18 price:7 Facet UI checkbox list User clicks a value, request re-issues with new filter clause loop back to search request

The worked example: a product catalog with brand (keyword), color (keyword), price (float), and created (date). The user searches “running shoes” and the sidebar must show how many results exist per brand, per color, per price band. Each of those is one aggregation. A terms aggregation buckets by exact value (brand, color). A range aggregation buckets price into named bands you define. A date_histogram buckets created by calendar interval. When facet values live inside an array of objects — sizes with their own stock counts, say — a nested aggregation reaches into that sub-document scope.

Two semantic rules govern multi-select. AND-within-facet means selecting two different facets (brand AND color) intersects: a document must match both. OR-across-values means selecting two values of the same facet (Red OR Blue) unions: a document matching either survives. Mixing these correctly is what post_filter and per-facet filtered aggregations exist to solve, covered in depth under dynamic facet counts and post-filtering.

The distinction between facet and filter is worth nailing down because it drives the request shape. A filter is a constraint the user has already applied — it belongs in filter context, where it skips relevance scoring and becomes cacheable in the query cache. A facet is a count the engine computes so the user can decide what to apply next. The same keyword field can play both roles in one request: color is a filter when the user has checked Red, and simultaneously a facet because the response still reports how many Blue and Green documents exist. Treating these as one thing is the root of nearly every faceting bug, because the moment you let a filter leak into the aggregation scope, the facet stops describing the catalog and starts describing the already-filtered subset.

The reason scoping matters so much is execution order. Elasticsearch evaluates the query first to produce the matching document set, runs every aggregation over that set, and only then applies any post_filter to the hits that get returned. Aggregations therefore never see the post_filter. That single ordering rule is the lever you pull for dynamic counts: anything in query narrows both hits and facets, while anything in post_filter narrows only the hits. Per-facet filter aggregations give you the third option — narrowing each facet independently — by re-stating constraints inside the aggregation tree rather than at the top level.

Cardinality is the other axis that shapes the design. A terms aggregation builds an ordinal map of every distinct value it encounters; for a low-cardinality field like color (a dozen values) that map is trivial, but for something like seller_id with hundreds of thousands of values it dominates query time and memory. High-cardinality facets are usually a UX smell — a sidebar with 50,000 checkboxes helps no one — so the engineering move is to cap terms.size, lean on eager_global_ordinals, and consider a search-as-you-type filter input instead of an exhaustive checkbox list. Hierarchical facets compound this: a department-by-type breakdown with depth_first collection materializes every leaf bucket before pruning, so a wide tree can explode the intermediate result set even when the final rendered facet is small.

Step-by-Step Implementation

1. Create an index with aggregatable mappings

curl -X PUT "localhost:9200/products" -H 'Content-Type: application/json' -d '{
  "mappings": {
    "properties": {
      "title":   { "type": "text" },
      "brand":   { "type": "keyword", "eager_global_ordinals": true },
      "color":   { "type": "keyword" },
      "price":   { "type": "float" },
      "created": { "type": "date" },
      "variants": {
        "type": "nested",
        "properties": {
          "size":  { "type": "keyword" },
          "stock": { "type": "integer" }
        }
      }
    }
  }
}'

Verify: confirm the mapping applied and brand is a keyword, not auto-mapped text.

curl -s "localhost:9200/products/_mapping" | jq '.products.mappings.properties.brand.type'
# expected: "keyword"

2. Issue a query with the four aggregation types

POST localhost:9200/products/_search
{
  "size": 10,
  "query": {
    "bool": {
      "must": [{ "match": { "title": "running shoes" } }]
    }
  },
  "aggs": {
    "by_brand": { "terms": { "field": "brand", "size": 20 } },
    "by_color": { "terms": { "field": "color", "size": 20 } },
    "by_price": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 50, "key": "under-50" },
          { "from": 50, "to": 100, "key": "50-100" },
          { "from": 100, "key": "100-plus" }
        ]
      }
    },
    "by_month": {
      "date_histogram": { "field": "created", "calendar_interval": "month" }
    },
    "by_size": {
      "nested": { "path": "variants" },
      "aggs": {
        "sizes": { "terms": { "field": "variants.size", "size": 15 } }
      }
    }
  }
}

Verify: the response carries an aggregations object with one entry per named agg, each holding buckets.

curl -s -X POST "localhost:9200/products/_search" -H 'Content-Type: application/json' \
  -d @query.json | jq '.aggregations.by_brand.buckets'
# expected: [ { "key": "acme", "doc_count": 42 }, ... ]

3. Apply a single-facet filter (AND semantics)

When the user picks one value from one facet and nothing else, just add it to filter context. Filter context skips scoring and is cacheable.

{
  "query": {
    "bool": {
      "must":   [{ "match": { "title": "running shoes" } }],
      "filter": [{ "term": { "brand": "acme" } }]
    }
  },
  "aggs": { "by_color": { "terms": { "field": "color", "size": 20 } } }
}

Verify: the by_color counts now reflect only Acme products.

curl -s -X POST "localhost:9200/products/_search" -H 'Content-Type: application/json' \
  -d @filtered.json | jq '.hits.total.value'

4. Multi-select within one facet (OR semantics)

Selecting Red OR Blue is a terms query (a list of values) in filter context, not two term clauses.

{
  "query": {
    "bool": {
      "filter": [{ "terms": { "color": ["red", "blue"] } }]
    }
  }
}

Verify: total hits should equal red-count + blue-count minus any overlap.

5. Hierarchical facets with a sub-aggregation

Category trees (Department → Type → Subtype) nest terms aggregations. The outer bucket carries the inner breakdown.

{
  "aggs": {
    "department": {
      "terms": { "field": "dept", "size": 10 },
      "aggs": {
        "type": { "terms": { "field": "type", "size": 10 } }
      }
    }
  }
}

Verify: each department bucket exposes a type.buckets array, letting the UI lazy-expand a branch on click.

The end-to-end wiring — parsing these buckets into checkbox state and feeding selections back as filter clauses — is walked through in building faceted filters with aggregations.

6. Choose the right aggregation for each facet type

The four aggregation families map onto distinct field types, and picking the wrong one produces silently wrong buckets. A terms aggregation suits discrete, bounded vocabularies — brand, color, material, status — where each distinct value is its own bucket. A range aggregation suits a continuous numeric field where you want named bands the user recognizes (price tiers, rating thresholds); you define the boundaries, so the buckets are stable and labelled. A date_histogram suits time facets where the buckets should follow calendar or fixed intervals rather than fixed counts, which keeps “this month” meaning a month even across daylight-saving boundaries. A nested aggregation is mandatory whenever the facet value lives inside an array of objects that must be kept correlated — a size with its own stock count, a variant with its own price — because flattening would let a query match across unrelated array entries.

{
  "aggs": {
    "available_sizes": {
      "nested": { "path": "variants" },
      "aggs": {
        "in_stock": {
          "filter": { "range": { "variants.stock": { "gt": 0 } } },
          "aggs": { "sizes": { "terms": { "field": "variants.size" } } }
        }
      }
    }
  }
}

Verify: the response counts only sizes that still have stock, because the filter sub-agg runs inside the nested scope where size and stock stay paired per variant.

Configuration Reference

Name Default Type Effect
terms.size 10 integer Max buckets returned per terms agg; too low truncates rare facet values and corrupts counts
terms.shard_size size * 1.5 + 10 integer Buckets gathered per shard before reduce; raise it to improve count accuracy on high-cardinality fields
terms.min_doc_count 1 integer Drops buckets below this count; set to 0 to show zero-result facet values (expensive)
eager_global_ordinals false boolean Builds global ordinals at refresh, not query time; cuts first-query latency on high-cardinality keyword facets
terms.execution_hint global_ordinals string map trades memory for speed on low-cardinality fields; default suits most facets
date_histogram.calendar_interval none string Calendar-aware bucket width (month, week); use over fixed_interval for human-readable date facets
aggregations.collect_mode depth_first string breadth_first curbs combinatorial blowup on nested high-cardinality sub-aggs

Failure Modes & Debugging

Symptom: facet counts are slightly wrong on high-cardinality fields

Root cause: each shard returns only its top shard_size terms, so a value that ranks just below the cutoff on every shard gets undercounted at reduce time. The doc_count_error_upper_bound in the response quantifies the worst-case error. Remediation: raise shard_size, or reduce shard count for the index.

curl -s -X POST "localhost:9200/products/_search" -H 'Content-Type: application/json' \
  -d '{"aggs":{"by_brand":{"terms":{"field":"brand","size":50,"shard_size":200}}}}' \
  | jq '.aggregations.by_brand.doc_count_error_upper_bound'
# expected: 0 once shard_size is large enough
Symptom: selecting one facet value empties all other facets

Root cause: the selected term was added to the main query, so the aggregations now run over the narrowed hit set and every other value drops to zero. Remediation: move the active facet’s filter into a post_filter, or compute that facet’s counts in a global-scoped sub-aggregation. This is the core problem solved in the dynamic facet counts guide.

# Confirm the filter is in query context (the bug) vs post_filter (the fix)
jq '.query.bool.filter, .post_filter' request.json
Symptom: nested facet returns zero buckets despite matching documents

Root cause: a terms aggregation on a nested field path without wrapping it in a nested aggregation first; the field is invisible at root scope. Remediation: wrap the inner agg in { "nested": { "path": "variants" } } so the engine descends into the sub-document scope.

curl -s "localhost:9200/products/_mapping" | jq '.products.mappings.properties.variants.type'
# expected: "nested" — if "object", the path-based agg behaves differently
Symptom: aggregation latency spikes under concurrent load

Root cause: global ordinals rebuilt on every query because the field is high-cardinality and eager_global_ordinals is off, or breadth_first is missing on a deeply nested agg. Remediation: enable eager global ordinals on hot facet fields and set collect_mode: breadth_first on combinatorial sub-aggregations.

curl -s "localhost:9200/products/_stats/fielddata?fields=brand" | jq '.indices'

Performance & Scale Notes

Facet cost scales with cardinality, not document count. A terms agg over a 5-value color field is nearly free; the same agg over a 2-million-value user_id field can dominate query time. As a rule of thumb, keep terms facets under ~10,000 unique values; above that, prefetch with eager_global_ordinals: true so the ordinal map is built at refresh rather than on the request path — this trades a few milliseconds of refresh time for first-query latency that can otherwise reach 200-400 ms cold.

Measure with the Profile API ("profile": true in the request body) and read the aggregations timing breakdown; build_aggregation and collect are the two costs to watch. On a 3-shard index with 5 facets over 1M documents, expect single-digit-millisecond aggregation overhead when ordinals are warm. Use breadth_first collection once a hierarchical facet exceeds roughly 1,000 parent buckets, since depth_first materializes every child bucket before pruning.

Shard count is a hidden multiplier on count accuracy. Each shard computes its own top-shard_size terms independently and the coordinating node merges them, so a value that is globally popular but locally just below the cutoff on several shards gets undercounted. The response surfaces this as doc_count_error_upper_bound; when it is non-zero on a facet that must be exact, either raise shard_size (the cheap fix, at the cost of more memory during reduce) or reduce the index’s shard count so fewer independent cutoffs exist. For most product catalogs a single shard up to tens of millions of documents both simplifies count accuracy and avoids the cross-shard merge cost entirely.

Per-facet filtered aggregations — the technique that keeps multi-select counts correct — multiply work, because each facet effectively re-runs the query with a different filter set. Budget for this: five facets with per-facet filtering cost roughly five times a single aggregation pass, though warm ordinals and the query cache absorb much of it. If that overhead becomes the bottleneck, the lever is to compute expensive facets less often (debounce, or only on explicit “apply”) rather than on every keystroke. For facets that drive ranking decisions rather than counts, cross-reference the boosting approaches in ranking algorithms and relevance tuning.