for Faceted Search on Product Variants
As soon as a product has multiple variants with their own size, color and price, classic aggregation fails silently and miscounts. The nested aggregation operates on a separate, isolated Lucene index inside the parent document, which yields correct facets at variant level. With reverse_nested you can additionally jump back into the parent context to build facets that correctly connect both variant and product level.
Table of Contents
- 1. Why Faceted Search Needs Nested Aggregations
- 2. The nested Mapping as a Prerequisite
- 3. The Nested Aggregation in Detail
- 4. reverse_nested: Back to the Parent Context
- 5. Faceted Navigation with Product Variants
- 6. Filters Inside Nested Aggregations
- 7. Performance Pitfalls with Nested Fields
- 8. nested vs. flattened: Choosing the Right Mapping
- 9. Nested vs. Non-Nested Aggregation Compared
- 10. Summary
- 11. FAQ
1. Why Faceted Search Needs Nested Aggregations
Faceted search lives on filter values being counted correctly: how many t-shirts exist in size M, how many in blue? As soon as these attributes do not sit directly on the product document but on individual variants inside the product, a structural problem arises. If you store variants as a plain array of objects, Elasticsearch internally flattens all values of each field into a single flat list per field at index time. The relationship between "size M" and "color blue" within the same variant is completely lost in the process.
The result: a terms aggregation on variants.color combined with a filter on variants.size: M also returns colors that only exist for entirely different sizes, because Elasticsearch does not know which color belongs to which size. This is exactly the problem the nested field type solves in combination with the nested aggregation: each variant is stored as an independent, hidden Lucene document, which preserves the relationship between the attributes of a single variant and allows facets to be counted correctly.
2. The nested Mapping as a Prerequisite
For a nested aggregation to work at all, the affected field must be explicitly declared as type nested in the mapping, not as a plain object. The difference is fundamental: an object field gets internally flattened into one document, while a nested field creates a separate, hidden Lucene document for each array element that stays connected to the parent document via an internal join mechanism. That costs more storage and time at index time, but it is the only correct solution once relationships between fields within an array element need to be preserved.
A common mistake is trying to change an existing object mapping to nested after the fact: that is not possible without reindexing, because the internal Lucene document structure changes. Anyone planning faceted search over variants should therefore fix the nested mapping in the index template from the start, before production data is loaded.
PUT /products
{
"mappings": {
"properties": {
"name": { "type": "text" },
"variants": {
"type": "nested",
"properties": {
"sku": { "type": "keyword" },
"size": { "type": "keyword" },
"color": { "type": "keyword" },
"price": { "type": "float" },
"in_stock": { "type": "boolean" }
}
}
}
}
}
// "nested" preserves the relation between size, color and
// price within a single variant, unlike a plain "object" field
3. The Nested Aggregation in Detail
The nested aggregation itself does not create a bucket in the classic sense, but opens a new evaluation context: every sub-aggregation inside a nested aggregation no longer operates on the parent documents but on the hidden child documents at the given path. Only within this context does a terms aggregation on variants.color return correct, isolated counts, because it only sees the actual variant documents, without mixing in the parent structure.
It matters that the path parameter exactly matches the path of the nested field defined in the mapping. For multi-level nested structures, for example variants with further nested stock levels per location, nested aggregations can be nested inside each other, with each level declaring its own path. That nesting follows the same logic as normal bucket aggregations, but operates on a progressively deeper Lucene document context.
POST /products/_search
{
"size": 0,
"aggs": {
"variant_colors": {
"nested": { "path": "variants" },
"aggs": {
"colors": {
"terms": { "field": "variants.color", "size": 20 }
},
"avg_variant_price": {
"avg": { "field": "variants.price" }
}
}
}
}
}
// All sub-aggregations here operate on variant documents,
// not on the parent product document
4. reverse_nested: Back to the Parent Context
Inside a nested aggregation you no longer have direct access to fields of the parent document, such as the product category or brand. This is exactly where reverse_nested comes in: this aggregation jumps out of the nested context back into the context of the parent document, without leaving the surrounding bucket. That lets you answer how many distinct products, not variants, actually offer a given color, a difference that matters when a product has several variants of the same color.
Without reverse_nested, a count inside the nested context would always return the number of variants, not the number of products. For a product with three blue variants in different sizes, a plain terms aggregation on variants.color counts three hits for blue, even though it is a single product. With reverse_nested and a nested cardinality aggregation on the parent ID, that distortion can be corrected to determine the actual product count per color.
POST /products/_search
{
"size": 0,
"aggs": {
"variant_colors": {
"nested": { "path": "variants" },
"aggs": {
"colors": {
"terms": { "field": "variants.color", "size": 20 },
"aggs": {
"distinct_products": {
"reverse_nested": {},
"aggs": {
"product_count": {
"cardinality": { "field": "_id" }
}
}
}
}
}
}
}
}
}
// reverse_nested jumps back to the parent document context
// distinct_products counts actual products, not variants
5. Faceted Navigation with Product Variants
In practice, a faceted category page combines several nested aggregations at the same time: one for colors, one for sizes, often a third for availability. Each of these facets has to be computed independently of the selection of the others, so a user who already selected "size M" still sees, in the color facet, every color available for size M, not just colors matching the currently filtered combination. This pattern is often called "sticky facets" in the Elasticsearch community and is implemented via a combination of post_filter and several parallel nested aggregation branches, each carrying its own, deliberately reduced filter inside the nested context.
A practical example: the size facet should respect the color selection but not its own size selection. To do that, a filter aggregation block is inserted inside the nested aggregation that only filters by color, while the outer query applies every other filter independently of the facet. This technique allows several independently filtered facet branches to be computed in a single request, without having to send multiple requests to the cluster.
6. Filters Inside Nested Aggregations
To correctly filter attribute combinations within a single variant, for example "size M AND color blue at the same time within the same variant", the filter itself must be formulated as a nested query, not as a plain top-level bool.filter. A nested query with bool.must on variants.size and variants.color guarantees that both conditions must be satisfied on the same child document, whereas two separate top-level filters would also match even if size and color come from different variants of the same product.
POST /products/_search
{
"query": {
"nested": {
"path": "variants",
"query": {
"bool": {
"must": [
{ "term": { "variants.size": "M" } },
{ "term": { "variants.color": "blue" } },
{ "term": { "variants.in_stock": true } }
]
}
}
}
},
"aggs": {
"variant_colors": {
"nested": { "path": "variants" },
"aggs": {
"colors": { "terms": { "field": "variants.color" } }
}
}
}
}
// The nested query guarantees size, color and stock status
// match within the SAME variant document
7. Performance Pitfalls with Nested Fields
Every nested object creates an additional hidden Lucene document that is stored in the same segment as the parent document. A product with fifty variants therefore occupies fifty-one Lucene documents instead of one. In large product catalogs with many variants per product, this can multiply the total number of Lucene documents in the index, which correspondingly increases index size, merge overhead and memory consumption. Elasticsearch therefore limits the number of nested documents per parent to 10000 by default via index.mapping.nested_objects.limit, a limit that deserves a deliberate check for very variant-heavy catalogs.
A second performance aspect concerns the join computation itself: every nested aggregation and every nested query has to resolve the relationship between parent and child documents at runtime, which costs extra CPU cycles compared to a flat aggregation. For category pages with a high hit count and several parallel facet branches, you should therefore measure whether response time remains acceptable, and reduce the number of simultaneously computed facets if necessary.
8. nested vs. flattened: Choosing the Right Mapping
Not every array structure necessarily needs nested. The flattened field type stores an entire JSON object as a single, unstructured unit and suits data with an unknown or highly variable schema where no relationships between individual sub-fields need to be evaluated through an aggregation. For product variants with a fixed schema and a clear need to correctly compute facets on combinations of size, color and price, however, nested is practically the only option.
A third option is denormalization: instead of keeping variants as an array inside the product document, each variant is stored as an independent document in a separate index, with a reference to the parent product. This strategy avoids the nested join cost entirely, but requires separate aggregation queries against the variant index and additional linking logic at the application level whenever product and variant level need to be displayed together.
// flattened: entire object as one opaque unit, no relations kept
PUT /reviews
{
"mappings": {
"properties": {
"raw_attributes": { "type": "flattened" }
}
}
}
// Good for unknown/variable schema, no relation between sub-fields
// nested: preserves relations, required for correct facets
PUT /products
{
"mappings": {
"properties": {
"variants": {
"type": "nested",
"properties": {
"size": { "type": "keyword" },
"color": { "type": "keyword" }
}
}
}
}
}
// Choose nested when sub-field combinations must be queryable
9. Nested vs. Non-Nested Aggregation Compared
The table below compares how aggregations differ on object fields versus nested fields.
| Aspect | object Aggregation | Nested Aggregation |
|---|---|---|
| Relationship between fields | Lost at index time | Preserved per array element |
| Lucene documents | One document per parent | One document per array element plus parent |
| Aggregation syntax | Direct terms/range aggregation | Requires nested wrapper with path |
| Access to parent fields | Directly available | Only via reverse_nested |
| Index size | Smaller | Larger due to extra child documents |
| Facet correctness | Incorrect for combined attributes | Correct, attribute combination preserved |
Choosing nested is therefore a deliberate trade-off between index size and query complexity on one side, and facet counting correctness on the other. For product variants with several jointly relevant attributes, there is practically no alternative that delivers the same accuracy.
Mironsoft
Faceted search, product variants and Elasticsearch mapping design
Facets that count correctly on product variants?
We design nested mappings and aggregation queries for variant-heavy catalogs, fix incorrect facet counts, and optimize the performance of complex faceted search pages.
Mapping Design
Evaluate nested vs. flattened vs. denormalization for your case
Facet Fixes
Implement reverse_nested and sticky facets for correct counts
Performance Tuning
Optimize nested document counts and query times at large catalog scale
10. Summary
The nested aggregation is the only correct solution for computing facets on fields modeled as an array of objects with several attributes that belong together, such as product variants with size, color and price. It requires a nested mapping instead of a plain object field, which creates a separate, hidden Lucene document for each array element and thereby preserves the relationship between the attributes of a single variant. Inside the nested aggregation, terms, range or metric aggregations return correct, isolated values at variant level.
With reverse_nested you can jump back into the parent context, for example to count the actual number of distinct products instead of the number of variants. For filter queries on combinations of several variant attributes, a nested query must additionally be used so the conditions are guaranteed to be checked on the same child document. The price of this correctness is a larger index and extra join overhead at runtime, which should be measured for very variant-heavy catalogs and, in case of doubt, weighed against alternatives such as denormalization.
Nested Aggregation for Faceted Search, the Key Points at a Glance
nested Mapping
Required for correct facets on array fields with attributes that belong together, like size and color.
nested Aggregation
Opens the child document context, sub-aggregations then count correctly at variant level.
reverse_nested
Jumps back to the parent context, important for distinct product counts instead of variant counts.
Cost
Larger index due to extra Lucene documents, more CPU for join resolution at runtime.