the Aggregations Behind It
Every filter option in Magento layered navigation, from brand selection to the price slider, is the result of an Elasticsearch aggregation computed in the background alongside the actual search request. Understanding how terms and range aggregations are built lets you add your own facets and fix navigation performance issues at the right place.
Table of Contents
- 1. What layered navigation technically really means
- 2. From filterable attribute to bucket aggregation
- 3. Terms aggregations for dropdown and multiselect
- 4. Range aggregations for the price filter
- 5. LayerResolver and bucket reader in Magento
- 6. The complete aggregation request body
- 7. Performance: aggregations and post-filters
- 8. Adding custom attributes to the navigation
- 9. Practical example: a custom range facet for a custom attribute
- 10. Summary
- 11. FAQ
1. What layered navigation technically really means
Layered navigation is the filter bar that appears on category and search result pages in Magento: brand, price, color, size, and every other filterable attribute. What looks at first glance like a simple list of checkboxes is, in the background, the result of an Elasticsearch aggregation executed alongside the product search. Every filter option, every count in parentheses behind a filter value, comes from a so-called bucket that Elasticsearch computed specifically for that request.
The crucial point for understanding layered navigation is that these aggregations are not computed separately but as part of the very same search request that also delivers the product list. A single request to Elasticsearch returns both the hit IDs for the product list and the bucket data for all filters at once. This is a substantial efficiency gain over an approach that would issue a separate database query for every filter option, as the classic MySQL-based faceted search used to do.
This article traces layered navigation from attribute configuration to the finished aggregation request: which aggregation types exist, how Magento derives them from attribute data, how performance problems arise, and how you can add your own facets for custom attributes.
2. From filterable attribute to bucket aggregation
Not every product attribute automatically appears in layered navigation. What decides this is the is_filterable attribute flag, configured in the backend under "Use in Layered Navigation." This flag knows several values: "Filterable (with results)" only shows filter options with at least one hit, "Filterable (no results)" also shows empty options. This configuration directly controls how Magento builds the later Elasticsearch aggregation, in particular the min_doc_count parameter, which filters buckets with zero hits out of the response, or not.
Once an attribute is marked filterable, its EAV backend type and frontend input decide which Elasticsearch aggregation type is used. A dropdown or multiselect attribute with discrete values leads to a terms aggregation, which returns a bucket with a hit count for every value present. Price and other numeric attributes with a continuous value distribution, on the other hand, lead to a range aggregation, which splits the value range into fixed or dynamically computed intervals.
This mapping happens in Magento via a series of filter classes implemented per attribute type: Magento\Catalog\Model\Layer\Filter\Attribute for discrete attributes, Magento\Catalog\Model\Layer\Filter\Price for the price filter, Magento\Catalog\Model\Layer\Filter\Category for category navigation. Each of these classes knows both the logic for building the aggregation request and for converting the returned buckets into displayable filter options with a label and hit count.
3. Terms aggregations for dropdown and multiselect
The terms aggregation is the most common aggregation type in layered navigation and is used for every attribute with a bounded value set, such as brand, color or material. Technically, Elasticsearch groups all documents by the exact value of a keyword field and returns a bucket with the value itself and the hit count for every unique value. Important: the terms aggregation works exclusively on keyword fields, not on text fields, because it counts exact values, not tokens.
The size parameter within the terms aggregation limits how many distinct buckets are returned. For attributes with many possible values, such as color options in a large assortment, this value must be set deliberately, otherwise Elasticsearch returns only the ten most frequent values by default and hides rarer options from the navigation entirely. Magento usually sets this value high enough to cover all relevant options, but for very large attribute value sets an explicit check is worthwhile.
GET /magento2_product_1_v1/_search
{
"size": 0,
"query": { "bool": { "filter": [ { "term": { "category_ids": 42 } } ] } },
"aggs": {
"brand_bucket": {
"terms": { "field": "brand", "size": 50, "min_doc_count": 1 }
},
"color_bucket": {
"terms": { "field": "color", "size": 30, "min_doc_count": 1 }
}
}
}
4. Range aggregations for the price filter
The price filter of layered navigation uses a range aggregation that splits the continuous value range of a numeric field into discrete intervals. Magento does not calculate these intervals statically but dynamically based on the actual price distribution of the currently filtered products, controlled via the "Price Navigation Step Calculation" configuration with the options "Automatic (Equalize Product Counts)" and "Automatic (Equalize Price Ranges)." The first variant tries to fit a similar number of products into each price interval, the second splits the value range into equally sized price spans.
Technically, Elasticsearch uses either a classic range aggregation with precomputed boundaries or a histogram aggregation with a fixed interval width for this case, depending on the chosen calculation method. With "Equalize Product Counts," Magento must first determine the percentiles of the price distribution before sending the actual range aggregation with individual boundaries, which in practice can result in two consecutive Elasticsearch requests instead of a single combined one.
GET /magento2_product_1_v1/_search
{
"size": 0,
"query": { "bool": { "filter": [ { "term": { "category_ids": 42 } } ] } },
"aggs": {
"price_bucket": {
"range": {
"field": "price",
"ranges": [
{ "to": 50 },
{ "from": 50, "to": 100 },
{ "from": 100, "to": 250 },
{ "from": 250 }
]
}
}
}
}
5. LayerResolver and bucket reader in Magento
On the Magento side, the LayerResolver (Magento\Catalog\Model\Layer\Resolver) coordinates whether the category layer or the search layer is active, depending on whether a category page or a search result page is rendered. Both layers use the same underlying aggregation logic but differ in additional filter conditions: the category layer always filters by the current category ID, the search layer by the fulltext search term.
The returned buckets are not passed through to the frontend directly, but translated into a unified, engine-agnostic format by the Magento\Framework\Search\Response\Aggregation class and its associated Bucket and Value objects. This abstraction layer is the reason layered navigation templates in the frontend do not need to know whether Elasticsearch or, theoretically, another search engine computed the aggregation in the background. Only the filter classes in the layer module enrich this generic bucket data with attribute labels and URL parameters for the filter links.
6. The complete aggregation request body
In practice, Magento combines all relevant aggregations for a category page with active filters into a single request, together with the actual product search. This means a request contains both the query clause for the product list and a complete aggs block with all active facets. The size: 0 parameter is deliberately not always set, because the same request usually also needs to return the top N products for display, not just the aggregation data.
An important detail: filters of layered navigation that are already active are typically excluded from the aggregation of their own attribute, while they continue to apply to all other aggregations. If a user selects a brand, for example, the price aggregation should still only consider products of that brand, while the brand aggregation itself shows all available brands within the category, not just the one already selected. This selective filter exclusion is implemented via separate filter aggregations wrapped around each terms or range aggregation.
GET /magento2_product_1_v1/_search
{
"size": 24,
"query": {
"bool": {
"filter": [
{ "term": { "category_ids": 42 } },
{ "term": { "brand": "acme" } }
]
}
},
"aggs": {
"price_bucket": {
"filter": { "bool": { "filter": [ { "term": { "category_ids": 42 } }, { "term": { "brand": "acme" } } ] } },
"aggs": { "prices": { "range": { "field": "price", "ranges": [ { "to": 50 }, { "from": 50 } ] } } }
},
"brand_bucket": {
"filter": { "bool": { "filter": [ { "term": { "category_ids": 42 } } ] } },
"aggs": { "brands": { "terms": { "field": "brand", "size": 50 } } }
}
}
}
7. Performance: aggregations and post-filters
Aggregations are inherently more compute-intensive in Elasticsearch than plain filter queries, because additional counts are performed across the entire filtered result set for every bucket. For categories with very large numbers of products and many facets active simultaneously in layered navigation, the number of parallel aggregations can noticeably increase response times, especially when several terms aggregations with a high size value are computed at once.
A proven approach to reduce this load is to deliberately limit the number of facets computed simultaneously, for example by not showing rarely used attributes in layered navigation by default, but loading them only after user interaction. In addition, doc_values, enabled by default for keyword and numeric fields, significantly speed up aggregations, because Elasticsearch then accesses a column-oriented data structure instead of the inverted index. Mapping a field with doc_values: false, for example for storage reasons, effectively makes that field unusable for aggregations.
| Attribute Type | Aggregation Type | Field Requirement | Typical Usage |
|---|---|---|---|
| Dropdown / Select | Terms | keyword field |
Brand, material, status |
| Multiselect | Terms on array field | keyword, multi-valued |
Color variants, tags |
| Price | Range or histogram | numeric field with doc_values |
Price slider |
| Category | Terms on category_ids | keyword array |
Subcategory navigation |
| Boolean | Terms with two buckets | boolean or keyword |
In stock, new arrival |
8. Adding custom attributes to the navigation
To make a new custom attribute usable in layered navigation, the attribute configuration in the backend alone is not always sufficient. Besides is_filterable, the attribute must actually end up as a keyword field in the Elasticsearch index via the FieldMapper mapping, otherwise the terms aggregation misses its target or returns unexpected tokens instead of exact values. For numeric custom attributes meant to be filtered as a range, you additionally need to make sure the field type is actually numeric and not incorrectly mapped as text.
For more complex cases, such as an attribute with its own bucket structure that does not match the standard schema, Magento offers the option of implementing custom filter classes that fulfill Magento\Catalog\Model\Layer\Filter\FilterInterface. Such a class can define a fully custom aggregation logic, for example a nested aggregation for a nested field that is not covered by the standard filter classes.
9. Practical example: a custom range facet for a custom attribute
A practical example is a custom attribute "delivery time in days" that should appear as its own range facet in layered navigation, with fixed buckets like "immediately available," "1-3 days," and "more than 3 days." Magento's default price filter is hard-wired to the price field, so a custom filter class is the right approach, applying the same range aggregation logic to the new attribute.
<?php
declare(strict_types=1);
namespace Mironsoft\SearchExtension\Model\Layer\Filter;
use Magento\Catalog\Model\Layer\Filter\AbstractFilter;
use Magento\Catalog\Model\Layer\Filter\FilterInterface;
use Magento\Framework\App\RequestInterface;
/**
* Custom layered navigation filter for the "delivery_days" attribute,
* exposed as fixed range buckets instead of the default terms aggregation.
*/
class DeliveryTimeFilter extends AbstractFilter implements FilterInterface
{
private const REQUEST_VAR = 'delivery_days';
/**
* Applies the selected delivery time range to the product collection.
*
* @param RequestInterface $request
* @return $this
*/
public function apply(RequestInterface $request): self
{
$filterValue = $request->getParam(self::REQUEST_VAR);
if (!$filterValue) {
return $this;
}
[$from, $to] = array_pad(explode('-', (string) $filterValue), 2, null);
$this->getLayer()->getProductCollection()->addFieldToFilter(
'delivery_days',
array_filter(['from' => $from, 'to' => $to])
);
return $this;
}
}
Mironsoft
Layered navigation, faceted search and Elasticsearch aggregations
Want filters that stay fast, even with large catalogs?
We build custom facets for custom attributes, optimize existing aggregation requests, and find the root cause when layered navigation slows down under load.
Custom facets
Custom filter classes for attributes outside the standard schema
Performance analysis
Profiling aggregation requests and reducing unnecessary facets
Mapping review
Making sure attributes are correctly mapped for aggregations
10. Summary
Layered navigation in Magento is not an independent data source but a visual layer on top of Elasticsearch bucket aggregations. Discrete attributes like brand or color are mapped through terms aggregations on keyword fields, numeric attributes like price through range or histogram aggregations. Both run in the same request as the actual product search, combined with selective filter aggregations that correctly account for filters already active for the respective other facet.
Anyone wanting to add custom facets for custom attributes needs to provide both the Elasticsearch mapping and a matching filter class in the layer module. Performance problems in layered navigation almost always arise from too many facets computed simultaneously with too high a size value, or from fields without doc_values. Knowing these relationships lets you extend facets deliberately instead of improvising in the frontend template with every new filter request.
Layered navigation and aggregations: the essentials at a glance
Terms aggregation
For discrete attributes like brand and color, works exclusively on keyword fields.
Range aggregation
For the price filter, intervals computed dynamically by product count or equal price span.
One request for everything
Product search and all facets run combined in a single Elasticsearch request.
Performance
Keep doc_values enabled, deliberately limit the number of simultaneous facets and bucket size.