Aggregations answer questions about groups of matching documents. A bucket aggregation defines groups; a metric aggregation computes a value inside its current group. The top-level query restricts the population used by all ordinary aggregations. size: 0 suppresses returned hits but does not stop aggregation calculation.
Aggregation names are chosen by the request author and appear as keys in the response. Use the names required by a task because they are part of the answer contract. In this trainer both aggs and aggregations are supported, but the maximum nesting is three aggregation levels and the bucket budget is 1000.
- Query matches
- Category A
- avg(price) in A
- Category B
- avg(price) in B
- Category A
A child metric sees only documents inherited from its parent bucket. Parallel siblings do not filter one another.
Examples use the public products dataset on Elasticsearch 9.4.3. Responses are shortened JSON fragments: timing, shard and index metadata are omitted. Numeric scores are illustrative and can vary. Examples teach the method; exercise values may differ.
Count products by brand
A terms aggregation creates buckets for exact indexed values. Here we group by category instead of brand. Each bucket has a key and doc_count. The terms size controls the number of returned buckets; the root size controls returned documents. These are unrelated parameters with the same name.
Use keyword fields for whole-value groups, such as category or brand.keyword. Default terms ordering favors larger counts, with deterministic tie handling by key. On multi-shard production data, top terms can have approximation considerations; this trainer uses one primary shard and a size large enough to include every value in these examples.
Try a different scenario
{
"size": 0,
"aggs": {
"categories": {
"terms": {
"field": "category",
"size": 10
}
}
}
}Elasticsearch response
{
"hits": {
"total": {
"value": 300,
"relation": "eq"
},
"hits": []
},
"aggregations": {
"categories": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": [
{
"key": "Accessories",
"doc_count": 75
},
{
"key": "Audio",
"doc_count": 75
},
{
"key": "Computers",
"doc_count": 75
},
{
"key": "Smartphones",
"doc_count": 75
}
]
}
}
}How to read this result
No hits are returned. categories.buckets lists keys and counts for the matching population, while hits.total still reports the total number of matched documents.
Average price
avg computes the mean of indexed numeric values in its current scope. The example calculates average price only for category Audio using a top-level query. The metric response contains value, not a list of products or a bucket count. Without a query it would describe the whole index.
Documents missing the metric field do not contribute a value to a standard average. If there are no usable numeric values, value is null rather than zero. Treat decimal results as numeric calculations: insignificant floating-point differences are not a change in meaning.
Try a different scenario
{
"size": 0,
"query": {
"term": {
"category": "Audio"
}
},
"aggs": {
"audio_average": {
"avg": {
"field": "price"
}
}
}
}Elasticsearch response
{
"hits": {
"total": {
"value": 75,
"relation": "eq"
},
"hits": []
},
"aggregations": {
"audio_average": {
"value": 859.4133333333333
}
}
}How to read this result
audio_average.value summarizes every matching Audio product, not merely a first page. The metric name is chosen by this example.
Price boundaries
min and max are independent metric aggregations, so place them as siblings in aggs. The example limits the population to Accessories and asks for both price endpoints. A metric always inherits the current scope: neither sibling changes the document set of the other.
These endpoints are observed values, not the boundaries of a range filter. If a query allows price below 800, the maximum observed price can be lower than 800. Empty populations produce null metric values, so do not invent an extreme zero for an empty group.
Try a different scenario
{
"size": 0,
"query": {
"term": {
"category": "Accessories"
}
},
"aggs": {
"lowest": {
"min": {
"field": "price"
}
},
"highest": {
"max": {
"field": "price"
}
}
}
}Elasticsearch response
{
"hits": {
"total": {
"value": 75,
"relation": "eq"
},
"hits": []
},
"aggregations": {
"lowest": {
"value": 99
},
"highest": {
"value": 2499
}
}
}How to read this result
The two value fields describe the same Accessories population. They can be read without requesting or sorting individual products.
Aggregate in-stock products
A filter aggregation creates a single bucket containing documents that pass its query. Only its child aggregations inherit that restriction. In this example total hits still includes every product, but out_of_stock contains unavailable products and its average describes only those products.
A top-level query would restrict all aggregations and the search result itself. Choose the scope from the business question: one local comparison needs a filter bucket; one shared restriction for everything belongs in query. filter buckets expose doc_count, which helps verify that a metric used the intended population.
Try a different scenario
{
"size": 0,
"aggs": {
"out_of_stock": {
"filter": {
"term": {
"in_stock": false
}
},
"aggs": {
"average_price": {
"avg": {
"field": "price"
}
}
}
}
}
}Elasticsearch response
{
"hits": {
"total": {
"value": 300,
"relation": "eq"
},
"hits": []
},
"aggregations": {
"out_of_stock": {
"doc_count": 100,
"average_price": {
"value": 724.3
}
}
}
}How to read this result
Compare hits.total.value with out_of_stock.doc_count. They describe different populations because the restriction is local to the aggregation.
Metrics per brand
A bucket can contain child aggs. Group categories, then calculate average_price for each category. This computes several separate averages rather than one global average copied next to every bucket. The response shape mirrors the request tree, so read it from the parent key toward its descendants.
Nesting aggregations is not the same feature as a nested query on a nested-mapped field. Our products contain ordinary scalar fields; no nested field mapping is needed for a category-to-metric tree. Keep the hierarchy within the trainer limits and estimate bucket multiplication before adding another grouping.
Try a different scenario
{
"size": 0,
"aggs": {
"categories": {
"terms": {
"field": "category",
"size": 10
},
"aggs": {
"average_price": {
"avg": {
"field": "price"
}
}
}
}
}
}Elasticsearch response
{
"hits": {
"total": {
"value": 300,
"relation": "eq"
},
"hits": []
},
"aggregations": {
"categories": {
"doc_count_error_upper_bound": 0,
"sum_other_doc_count": 0,
"buckets": [
{
"key": "Accessories",
"doc_count": 75,
"average_price": {
"value": 859.4
}
},
{
"key": "Audio",
"doc_count": 75,
"average_price": {
"value": 859.4133333333333
}
},
{
"key": "Computers",
"doc_count": 75,
"average_price": {
"value": 589.2
}
},
{
"key": "Smartphones",
"doc_count": 75,
"average_price": {
"value": 589.88
}
}
]
}
}
}How to read this result
Every category bucket contains its own average_price.value. The metric sits inside that bucket, which identifies its population.
Key takeaways
- Root size controls hits; terms.size controls buckets.
- Use keyword for exact-value buckets and numeric fields for numeric metrics.
- query restricts every ordinary aggregation; filter aggregation restricts its descendants.
- A metric belongs inside the bucket whose documents it summarizes.
- An empty numeric metric is null, not zero.
Official reference
Ready to put this into practice?
Confirm reading to save your place. Reading and exercise progress are tracked separately in this browser using your guest cookie.