Faceted search: Elasticsearch best practices explained
When you land on an e-commerce site and filter products by price, brand, and color while seeing how many items match each option, you're experiencing faceted search in action. Faceted search: Elasticsearch best practices explained is about building that exact experience efficiently—letting users narrow results with multiple filters while maintaining accurate counts that reflect the current dataset. This is one of the most common search patterns in production systems, and getting it right requires understanding how Elasticsearch aggregations work alongside filters.
Why faceted search matters
Faceted navigation isn't just a nice feature. It dramatically improves user discovery by showing what's actually available rather than forcing users to guess search terms. In ride-hailing apps, you might facet by vehicle type and price range. In travel platforms, by star rating and amenities. The challenge is that facets must stay synchronized with your current filtered results, and performance can degrade if you're not careful with your queries.
We've seen production systems where facet counts went stale or queries took seconds to respond because the aggregation strategy didn't scale with the dataset. The practices in this article come from building these systems at scale across e-commerce, SaaS, and travel products.
Facets from aggregations — Terms aggregations give filter options with counts
The foundation of faceted search is the terms aggregation. It groups documents by a field value and returns a count for each unique value. This is how you get "Brand: Nike (2,450), Adidas (1,890), Puma (756)" below your search results.
Let's build a real example with a product index. Suppose we're selling shoes and want to show facets for brand and category.
{
"size": 20,
"query": {
"match_all": {}
},
"aggs": {
"brands": {
"terms": {
"field": "brand.keyword",
"size": 50
}
},
"categories": {
"terms": {
"field": "category.keyword",
"size": 30
}
}
}
}The response gives you buckets with keys and document counts:
{
"aggregations": {
"brands": {
"buckets": [
{
"key": "Nike",
"doc_count": 2450
},
{
"key": "Adidas",
"doc_count": 1890
},
{
"key": "Puma",
"doc_count": 756
}
]
},
"categories": {
"buckets": [
{
"key": "Running",
"doc_count": 5100
},
{
"key": "Casual",
"doc_count": 4200
}
]
}
}
}
Key considerations when setting up terms aggregations:
- Use
.keywordfields: Always aggregate onkeywordtype, not analyzed text. Analyzed fields will fragment results and give you incorrect counts. - Set appropriate
size: Thesizeparameter limits how many buckets are returned. If you have 5,000 unique brands, a size of 50 will only return the top 50 by document count. For smaller facets, increase size to capture everything. - Nested aggregations: You can nest aggregations to get drill-down counts. For example, count sizes within each brand.
{
"aggs": {
"brands": {
"terms": {
"field": "brand.keyword",
"size": 50
},
"aggs": {
"sizes": {
"terms": {
"field": "size.keyword",
"size": 20
}
}
}
}
}
}post_filter — Apply selected filters without changing facet counts
Here's where most teams stumble. When a user selects "Nike" as a brand, you need to apply that filter to the search results. But if you use a regular bool query filter, your aggregations will only run on Nike products, and your category counts will show only categories that have Nike shoes. That's wrong—users expect category counts to show what's available across all brands, so they can explore further.
Enter post_filter. This filter runs after aggregations, so facet counts reflect the unfiltered dataset while results are still narrowed.
{
"size": 20,
"query": {
"match_all": {}
},
"aggs": {
"brands": {
"terms": {
"field": "brand.keyword",
"size": 50
}
},
"categories": {
"terms": {
"field": "category.keyword",
"size": 30
}
}
},
"post_filter": {
"term": {
"brand.keyword": "Nike"
}
}
}Now the results show only Nike shoes, but the brand aggregation still shows counts for all brands. The category aggregation shows all categories (including those without Nike shoes). This matches user expectations and enables better exploration.
For multiple selections (user picks "Nike" AND "Adidas"), use a bool query in the post_filter:
{
"post_filter": {
"bool": {
"should": [
{ "term": { "brand.keyword": "Nike" } },
{ "term": { "brand.keyword": "Adidas" } }
],
"minimum_should_match": 1
}
}
}A critical insight: post_filter is slower than pre-query filters because Elasticsearch must process more documents. Use it only for facet selections. For search text or unrelated filters, use the main query clause instead.
Range facets — Price ranges with range aggregations
Not all facets are discrete terms. Price ranges are far more useful than listing every unique price. Elasticsearch provides range aggregations for this exact use case.
{
"aggs": {
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "to": 50 },
{ "from": 50, "to": 100 },
{ "from": 100, "to": 200 },
{ "from": 200 }
]
}
}
}
}Response:
{
"aggregations": {
"price_ranges": {
"buckets": [
{
"key": "*-50",
"to": 50,
"doc_count": 1200
},
{
"key": "50-100",
"from": 50,
"to": 100,
"doc_count": 3400
},
{
"key": "100-200",
"from": 100,
"to": 200,
"doc_count": 2800
},
{
"key": "200-*",
"from": 200,
"doc_count": 890
}
]
}
}
}When a user selects a price range, apply it with post_filter too:
{
"post_filter": {
"range": {
"price": {
"gte": 50,
"lt": 100
}
}
}
}For even better UX, use auto_date_histogram for date ranges or generate dynamic price ranges based on min/max values in your data. You can query the min and max with a separate aggregation, then build ranges programmatically on the client side.
UX — Show counts and allow multiple selections
A faceted search interface isn't just the backend query. The frontend experience shapes how users interact with filters.
Display counts prominently: Show "Nike (2,450)" not just "Nike". Counts tell users if a filter will narrow results meaningfully. In our experience, users often skip filters with very low counts.
Support multi-select within a facet: Users should select both Nike and Adidas simultaneously. In the query, this becomes an OR operation (Elasticsearch should clause). Between facets it's AND (brand Nike AND price $50-100).
Show selected filters clearly: Display active filters as chips or pills so users know what's applied. Make them removable with a single click. Some UX teams also show "Clear all filters" buttons for faster reset.
Consider filter reordering: By default, terms aggregations return buckets sorted by document count (highest first). You might want alphabetical sorting for large facets to make finding a specific brand easier. Use the order parameter:
{
"aggs": {
"brands": {
"terms": {
"field": "brand.keyword",
"size": 50,
"order": { "_key": "asc" }
}
}
}
}Handle empty facets carefully: If a facet returns zero counts in a bucket, decide whether to hide it or show it grayed out. Most e-commerce sites hide empty options to reduce clutter.
Common mistakes
Mistake 1: Forgetting .keyword fields. Aggregating on analyzed text fields causes counts to fragment. "Nike" and "nike" become separate buckets. Always use .keyword for facets unless you have a specific reason not to.
Mistake 2: Using query filters instead of post_filter. Applying brand filters in the main query clause makes category aggregations only show categories with that brand. This breaks facet exploration. Reserve the main query for search text and use post_filter for facet selections.
Mistake 3: Not setting size limits on aggregations. Large datasets with high cardinality can cause memory bloat if your aggregation size is unbounded. Always set size appropriately. For facets shown in a dropdown, 50-100 is usually sufficient. For behind-the-scenes facets, 1000+.
Mistake 4: Ignoring performance with post_filter. post_filter runs after the query, so it scans potentially millions of documents. For high-traffic faceted searches, monitor query latency. If post_filter is the bottleneck, consider pre-filtering with a bool query on frequently used facets instead.
Mistake 5: Not validating user selections server-side. Frontend code sends facet selections. Always validate that the selected facet value actually exists in your index. Malformed selections can cause unexpected behavior.
Key takeaways
- Use
termsaggregations on.keywordfields to generate facet options with accurate counts. - Employ
post_filterto apply facet selections without changing aggregation counts—this enables proper exploration. - Use
rangeaggregations for continuous values like price, with predefined buckets for better UX. - Display counts in the UI, support multi-select within facets, and show active filters clearly.
- Monitor query latency when using post_filter on large datasets; pre-filtering may be faster in high-traffic scenarios.
- Validate all facet selections server-side to prevent unexpected behavior from malformed input.






Comments
Loading comments…