Aggregations: count, group, follow across time
Module 5 knew how to retrieve specific articles; here we ask the corpus for numbers and trends. Léa is preparing the Veille quarterly review: how many articles per category, how does the climate topic climb year over year, who writes the most? Elasticsearch aggregations answer these questions in a single request, without ever returning documents.
Two reflexes that change everything
size: 0 tells Elasticsearch to bring back no document: we only want the aggregations block. Forgetting size triggers 10 useless hits on top of the computation.
Aggregations live under the aggs key (alias aggregations). Every aggregation carries a name you choose, a type (terms, date_histogram…), parameters, and optionally sub-aggregations in its own aggs.
terms: count by value
terms groups documents by the exact value of a keyword field. It is the most-used aggregation.
GET news/_search
{
"size": 0,
"aggs": {
"by_category": {
"terms": { "field": "category", "size": 5 }
}
}
}
Expected result, buckets at the top:
- POLITICS — 32,739
- WELLNESS — 17,827
- ENTERTAINMENT — 16,058
- TRAVEL — 9,887
- STYLE & BEAUTY — 9,649
The corpus total is 200,853 documents; the sum of the first five buckets therefore equals 86,160, or 43% of the corpus. Elasticsearch also returns doc_count_error_upper_bound (a potential error margin tied to shard slicing; zero here since the index has a single shard) and sum_other_doc_count (the rest).
For authors, we target the keyword sub-field authors.raw:
GET news/_search
{
"size": 0,
"aggs": {
"top_authors": {
"terms": { "field": "authors.raw", "size": 5 }
}
}
}
The first five buckets:
- Reuters — 4,954
- Lee Moran — 2,433
- Ron Dicker — 1,915
- Ed Mazza — 1,328
- Cole Delbyck — 1,145
terms on a text field{"terms": {"field": "headline"}} fails or is expensive: headline is analyzed (titre_en) and has no fielddata by default. Always aggregate on a keyword (here headline.raw, category, authors.raw).
date_histogram: follow time
date_histogram slices the time axis into regular intervals. Since Elasticsearch 8, use calendar_interval (calendar-aligned: year, month, week, day) or fixed_interval (a fixed duration in hours or minutes). The old interval is removed.
GET news/_search
{
"size": 0,
"aggs": {
"by_year": {
"date_histogram": {
"field": "date",
"calendar_interval": "year",
"format": "yyyy"
}
}
}
}
The corpus runs from 2012-01-28 to 2018-05-26, so we see seven buckets (2012 to 2018). Empty buckets are omitted by default; add "min_doc_count": 0 with extended_bounds for a complete axis.
For a monthly step:
GET news/_search
{
"size": 0,
"aggs": {
"by_month": {
"date_histogram": {
"field": "date",
"calendar_interval": "month",
"format": "yyyy-MM"
}
}
}
}
(your number may vary slightly from one bucket to another depending on the dates).
range: intervals à la carte
Where date_histogram slices regularly, range defines named brackets.
GET news/_search
{
"size": 0,
"aggs": {
"before_after_2016": {
"range": {
"field": "date",
"ranges": [
{ "to": "2016-01-01", "key": "2012-2015" },
{ "from": "2016-01-01", "key": "2016-2018" }
]
}
}
}
}
cardinality: count distinct values
How many different authors in the corpus?
GET news/_search
{
"size": 0,
"aggs": {
"n_authors": {
"cardinality": { "field": "authors.raw" }
}
}
}
cardinality uses the HyperLogLog++ algorithm: fast and memory-bounded, but approximate. The precision_threshold parameter (default 3,000, maximum 40,000) tunes the precision/memory trade-off — below the threshold, the count is almost always exact; above it, the average relative error stays under 1 to 2%.
On news, 41 exact categories fit easily within the default precision. For a perfectly exact count on a very large set, go through composite (see below) and count buckets client-side.
avg, stats, min, max
Classic metrics apply to numeric and date fields. news has no business numeric field, but we can look at the oldest and newest dates:
GET news/_search
{
"size": 0,
"aggs": {
"date_bounds": { "stats": { "field": "date" } }
}
}
The response contains min = 2012-01-28, max = 2018-05-26 (with readable min_as_string / max_as_string variants).
top_hits: a sample inside each bucket
top_hits is a sub-aggregation that returns N representative documents per parent bucket. Ideal for "the most recent headline of each category".
GET news/_search
{
"size": 0,
"aggs": {
"by_category": {
"terms": { "field": "category", "size": 5 },
"aggs": {
"latest_headline": {
"top_hits": {
"size": 1,
"sort": [ { "date": "desc" } ],
"_source": ["headline", "date"]
}
}
}
}
}
}
Sub-aggregations: the power that counts
Nesting is what sets aggregations apart from a simple GROUP BY. Léa's question: how many articles per category, year by year, on POLITICS and WELLNESS?
GET news/_search
{
"size": 0,
"query": {
"terms": { "category": ["POLITICS", "WELLNESS"] }
},
"aggs": {
"by_category": {
"terms": { "field": "category", "size": 2 },
"aggs": {
"by_year": {
"date_histogram": {
"field": "date",
"calendar_interval": "year",
"format": "yyyy"
}
}
}
}
}
}
Every parent bucket (POLITICS, WELLNESS) carries its own by_year. The total for by_category.POLITICS is 32,739; that of by_category.WELLNESS is 17,827; everything fits into a single request.
filter and filters in aggregation
filter (singular) restricts the calculation to a sub-set without changing the global query.
GET news/_search
{
"size": 0,
"aggs": {
"recent": {
"filter": { "range": { "date": { "gte": "2017-01-01" } } },
"aggs": {
"by_category": { "terms": { "field": "category", "size": 5 } }
}
}
}
}
filters (plural) produces N named buckets in a single pass, a bit like range but for free-form criteria.
GET news/_search
{
"size": 0,
"aggs": {
"topics": {
"filters": {
"filters": {
"climate": { "match": { "headline": "climate change" } },
"election": { "match": { "headline": "election" } },
"health": { "match": { "headline": "health" } }
}
}
}
}
}
The climate bucket contains 2,834 documents (see module 5).
composite: paginate an aggregation
terms with size: 10000 is an anti-pattern: memory climbs and nothing guarantees you get every key back. composite paginates cleanly, with an after_key equivalent to search_after.
GET news/_search
{
"size": 0,
"aggs": {
"all_categories": {
"composite": {
"size": 100,
"sources": [
{ "cat": { "terms": { "field": "category" } } }
]
}
}
}
}
The response contains an after_key; you re-run passing it under "after": { "cat": "..." } until exhaustion. On news, two calls are enough to cover the 41 exact categories.
Order and size: three pitfalls
termsreturns buckets sorted bydoc_countdescending by default. To sort by key,"order": {"_key": "asc"}; by a sub-metric,"order": {"metric_name": "desc"}.- The default
sizeis 10. A top 20 requires"size": 20. - Across shards,
termsasks each shard forshard_sizekeys (defaultsize * 1.5 + 10) then merges.doc_count_error_upper_boundmeasures the uncertainty. Onnews(a single shard), the margin is zero.
Reading an aggregation response
The structure is regular:
hits.total.value: number of documents matched by thequery.aggregations.<name>.buckets: list of buckets.- Each bucket:
key(the value),doc_count(the count), and sub-aggregations under their name. - For a
date_histogram:key(epoch ms timestamp),key_as_string(formatted string).
Writing a small paragraph that walks each field on day one lets you effortlessly read any response later on.
Try it 1 — Top 3 categories in 2017
Write a request that returns the three most frequent categories, restricted to articles published in 2017. No document data is needed.
Solution
GET news/_search
{
"size": 0,
"query": {
"bool": {
"filter": [ { "range": { "date": { "gte": "2017-01-01", "lt": "2018-01-01" } } } ]
}
},
"aggs": {
"top3_2017": {
"terms": { "field": "category", "size": 3 }
}
}
}
The query narrows the analysis field to 2017 (as a filter, without score). The terms aggregation then works on this sub-corpus. Compare with the global ranking to see whether POLITICS still leads WELLNESS that year (your number may differ slightly).
Try it 2 — Number of Lee Moran articles per category
The brief states that Lee Moran wrote 2,433 articles in total. How many in COMEDY, WEIRD NEWS, ENTERTAINMENT, POLITICS?
Solution
GET news/_search
{
"size": 0,
"query": {
"term": { "authors.raw": "Lee Moran" }
},
"aggs": {
"by_category": {
"terms": { "field": "category", "size": 5 }
}
}
}
Expected buckets: COMEDY 779, WEIRD NEWS 391, ENTERTAINMENT 387, POLITICS 264, SPORTS 101. The sum (1,922) is lower than 2,433: the remainder spreads across the 36 other categories. Pass "size": 41 to see it all.
Try it 3 — Distinct authors per year
Is the corpus getting more contributive over time? Return a year → distinct author count (approx.).
Solution
GET news/_search
{
"size": 0,
"aggs": {
"by_year": {
"date_histogram": {
"field": "date",
"calendar_interval": "year",
"format": "yyyy"
},
"aggs": {
"distinct_authors": {
"cardinality": { "field": "authors.raw", "precision_threshold": 5000 }
}
}
}
}
}
Every by_year bucket carries a cardinality sub-aggregation. precision_threshold is raised to keep good accuracy on the 23,082 distinct authors listed in the corpus.
Key takeaways
size: 0isolates aggregations; useless hits go away.termsgroups bykeyword,date_histogramgroups bycalendar_intervalorfixed_interval.cardinalityis an approximate HyperLogLog++: tuneprecision_thresholdfor the accuracy you want.- Sub-aggregations reproduce the computation per parent bucket: categories by year, authors by category.
filter/filtersrestrict an aggregation without touching thequery;compositepaginates withafter_key.- Read the response in order:
buckets[].key,buckets[].doc_count,buckets[].<sub_agg>.
Troubleshooting
- "
Fielddata is disabled on text fields by default" on atermsoverheadline→ aggregate on thekeywordsub-field (headline.raw,authors.raw); do not re-enablefielddata. doc_count_error_upper_boundis non-zero and the top moves → raiseshard_sizeorsize; onnews(a single shard) the error is zero.date_histogramreturns 0 bucket → check that the field is trulydatewith./lab.sh es news/_mapping?filter_path=**.date; otherwise fix the mapping (module 4).- "
Trying to create too many buckets" → capsizeor switch tocompositewithsize: 100and pagination.