Tuning Performance Deliberately
The terms aggregation is one of the most widely used Elasticsearch features, yet it quickly becomes a performance bottleneck at high field cardinality. The shard_size parameter, doc_count_error_upper_bound as an accuracy indicator, and eager global ordinals for precomputing expensive data structures together decide whether a facet query responds in single-digit or triple-digit milliseconds. This article shows how to deliberately tune terms aggregations at millions of unique values without losing sight of result quality.
Table of Contents
- 1. Why Terms Aggregation Can Get Expensive
- 2. Understanding doc_count_error_upper_bound
- 3. Setting shard_size Correctly
- 4. Eager Global Ordinals
- 5. Aggregating High Cardinality Fields
- 6. execution_hint and Circuit Breakers
- 7. Composite Instead of Terms for Very Many Buckets
- 8. Monitoring: Profile API and Slow Log
- 9. Standard vs. Tuned Terms Aggregation Compared
- 10. Summary
- 11. FAQ
1. Why Terms Aggregation Can Get Expensive
The terms aggregation is conceptually simple: create one bucket per unique field value and count matching documents. The cost of this seemingly simple operation, however, does not grow linearly but with the cardinality of the aggregated field and the number of shards involved. Every shard first has to determine its own top buckets locally before the coordinating node merges all shard results into a final ranking. For a field with a few hundred unique values, that is trivial; for a field with millions of unique values, such as product SKUs or email addresses, the local pre-sorting per shard becomes the dominant cost component.
A second cost factor is building global ordinals, an internal data structure that assigns a compact integer to every unique field value to speed up comparisons and counting. This structure is built lazily by default, meaning on first access, which noticeably slows down the very first terms aggregation after a segment merge or restart. Anyone chasing the cause of a single slow request amid otherwise fast responses frequently finds the explanation right here.
2. Understanding doc_count_error_upper_bound
Since every shard only reports its own top-N buckets to the coordinating node, a value that narrowly misses the top-N on one shard but occurs very frequently on another shard can end up incorrectly undercounted in the final merge. The doc_count_error_upper_bound field in the response gives an upper error bound for this inaccuracy: the maximum possible error a value not included in a shard's top-N buckets could contribute to the final count in the worst case.
In addition, every individual bucket optionally returns a doc_count_error field when show_term_doc_count_error: true is set. That lets you assess per bucket how reliable the reported count actually is. A high error value on a displayed bucket is a clear signal that either shard_size should be increased or the number of shards involved reduced to improve the accuracy of the terms aggregation.
POST /orders/_search
{
"size": 0,
"aggs": {
"top_products": {
"terms": {
"field": "product_sku.keyword",
"size": 10,
"show_term_doc_count_error": true
}
}
}
}
// Response excerpt:
// "top_products": {
// "doc_count_error_upper_bound": 42,
// "sum_other_doc_count": 918273,
// "buckets": [
// { "key": "SKU-1001", "doc_count": 5210, "doc_count_error": 3 },
// { "key": "SKU-1002", "doc_count": 4980, "doc_count_error": 12 }
// ]
// }
// A high doc_count_error_upper_bound signals shard_size is too low
3. Setting shard_size Correctly
The shard_size parameter determines how many candidate buckets each individual shard locally determines and forwards to the coordinating node, before only the top size buckets are returned at the end. Elasticsearch sets shard_size to a value noticeably larger than size by default, typically size * 1.5 + 10, to improve accuracy without requiring the user to set this parameter explicitly. For values that are very unevenly distributed across shards, so-called data skew, this default sometimes is not enough.
Deliberately increasing shard_size improves the accuracy of the terms aggregation, but causes more work per shard during local top-N determination and more network traffic to the coordinating node, since each shard transfers more candidates. The rule of thumb: increase shard_size only once doc_count_error_upper_bound is actually too high in practice, instead of preemptively setting a very high value that degrades performance without a measurable accuracy gain.
POST /orders/_search
{
"size": 0,
"aggs": {
"top_products": {
"terms": {
"field": "product_sku.keyword",
"size": 10,
"shard_size": 100
}
}
}
}
// shard_size > size collects more candidates per shard
// before the final top-10 merge on the coordinating node
4. Eager Global Ordinals
Global ordinals are an index-wide, compressed mapping of unique field values to integer IDs that significantly speeds up terms aggregations and sorting on keyword fields. This structure is built by default on first access to a segment, which means: after every refresh, every segment merge or cluster restart, the first terms aggregation on an affected field is disproportionately slow, while subsequent requests benefit from the already built structure.
Setting eager_global_ordinals: true in the mapping of the affected field instructs Elasticsearch to proactively rebuild global ordinals in the background on every refresh, instead of waiting for the first search access. That shifts the cost from the first user request to the indexing and refresh process and is particularly worthwhile for fields aggregated in nearly every facet query, such as brand or category in an e-commerce index. For rarely aggregated fields, the extra background work usually is not worth it.
PUT /products/_mapping
{
"properties": {
"brand": {
"type": "keyword",
"eager_global_ordinals": true
}
}
}
// Rebuilds global ordinals on every refresh in the background
// Moves cost from first query to indexing/refresh cycle
5. Aggregating High Cardinality Fields
Fields with very high cardinality, such as email addresses, session IDs or unique order numbers with millions of distinct values, pose particular challenges for terms aggregation. Memory usage for global ordinals grows with the number of unique values, and the local top-N determination per shard has to run over correspondingly more candidates. For such fields, it is worth first asking the business question whether all unique values are really relevant, or whether coarser grouping, for example by domain instead of the full email address, already satisfies the actual analysis goal.
If the full cardinality is genuinely needed for business reasons, but only the plain count of unique values matters, not the individual buckets themselves, a cardinality aggregation is the far cheaper alternative to terms aggregation, because it is based on the HyperLogLog++ algorithm and does not need to materialize a full bucket list. If, on the other hand, every single value is actually needed, for example for a full export, terms aggregation with a sensibly bounded size or the composite aggregation covered in the next article is the right approach.
// Expensive: terms aggregation forces materializing every bucket
POST /orders/_search
{
"size": 0,
"aggs": {
"unique_emails": {
"terms": { "field": "customer_email.keyword", "size": 50000 }
}
}
}
// Risks circuit breaker, high memory usage
// Cheap: cardinality gives only the count, constant memory
POST /orders/_search
{
"size": 0,
"aggs": {
"unique_emails": {
"cardinality": { "field": "customer_email.keyword" }
}
}
}
6. execution_hint and Circuit Breakers
The execution_hint parameter controls which internal algorithm is used for the terms aggregation: map builds a hash table of observed values and is suited to cases where only a small fraction of the field's unique values occur in the current search result, while global_ordinals, the default in most modern Elasticsearch versions, uses the ordinals structure described above and is usually the better choice for repeated aggregations on the same field. In practice, this decision is mostly left to Elasticsearch itself, since the automatic heuristic makes the right choice in the vast majority of cases.
More important in production is dealing with the request circuit breaker, which aborts an aggregation before it overloads a node's available heap memory. A terms aggregation with a very high size value on a high cardinality field can trigger this breaker, resulting in a circuit_breaking_exception instead of a slow but successful request. This is a protection mechanism, not a bug: the right response is to reduce size or switch to a composite aggregation, not to blanket-raise the breaker threshold.
7. Composite Instead of Terms for Very Many Buckets
Terms aggregation is designed for top-N determination, not for fully iterating over all buckets of a high cardinality field. If size is artificially set very high to capture every value, memory usage on the coordinating node grows linearly with it, because all buckets have to be held in memory simultaneously inside the response object. For the use case of systematically walking through literally every bucket of a field, for example for a full export of all values, the composite aggregation with its after_key cursor mechanism is the far more robust and memory-friendly solution, covered in detail in the next article of this series.
As a rule of thumb: terms aggregation for top-N displays with size in the low double to triple digits, composite aggregation for complete, paginated iteration over potentially thousands or millions of buckets. Anyone unaware of this boundary typically tries to force a terms aggregation with size: 100000, which almost always leads to memory problems and, in the worst case, a node crash.
8. Monitoring: Profile API and Slow Log
To find out whether a slow terms aggregation is actually responsible for response time, the Profile API returns detailed timing per aggregation component, including the time spent building global ordinals, actual bucket collection and the final merge. This analysis should be run deliberately in a staging environment, since the Profile API itself creates extra overhead and should not stay permanently enabled in production.
For continuous production operation, the search slow log is better suited: it logs requests exceeding a configurable time threshold, including the full query and aggregation structure. A regular look into the slow log typically reveals which combinations of field, size and filter base most often lead to outliers in practice, providing concrete starting points for targeted eager_global_ordinals or shard_size tuning.
PUT /products/_settings
{
"index.search.slowlog.threshold.query.warn": "2s",
"index.search.slowlog.threshold.query.info": "500ms",
"index.search.slowlog.threshold.fetch.warn": "1s"
}
// Enable Profile API in staging for per-component timing
POST /products/_search
{
"profile": true,
"size": 0,
"aggs": {
"brands": { "terms": { "field": "brand.keyword" } }
}
}
// Response includes a "profile" section with timing breakdown
9. Standard vs. Tuned Terms Aggregation Compared
The table below shows which setting addresses which problem.
| Problem | Default Behavior | Tuning |
|---|---|---|
| Inaccurate bucket counts | Automatic shard_size, sometimes too low | Increase shard_size deliberately, check doc_count_error |
| Slow first request | Lazy global ordinals build | eager_global_ordinals: true in mapping |
| Only unique count needed | Terms with very high size | cardinality aggregation instead of terms |
| Full bucket iteration | Terms with size: 100000+ | Composite aggregation with after_key |
| Circuit breaker exception | Blanket-raise breaker threshold | Reduce size or use composite |
The vast majority of terms aggregation performance problems trace back to exactly these five patterns. Knowing them lets you diagnose a slow facet query within minutes instead of blanket-adding heap memory or nodes.
Mironsoft
Elasticsearch performance tuning for high cardinality aggregations
Terms aggregations that do not collapse under high cardinality?
We analyze slow log data, tune shard_size and eager global ordinals deliberately, and replace unsuitable terms aggregation patterns with more performant alternatives for your Elasticsearch cluster.
Performance Audit
Evaluate Profile API and slow log data, identify bottlenecks
Mapping Tuning
Enable eager_global_ordinals deliberately for facet fields
Query Redesign
cardinality and composite aggregation as replacements for unsuitable terms queries
10. Summary
Terms aggregation performance hinges on the interplay of a few, deliberately tuned parameters. doc_count_error_upper_bound and show_term_doc_count_error show when the distributed top-N determination becomes inaccurate. shard_size deliberately influences accuracy at the cost of more work per shard and should be raised reactively, not preemptively. eager_global_ordinals shifts the expensive build of the ordinals structure from the first user request to the refresh cycle and pays off for frequently aggregated fields.
At very high field cardinality, it is worth asking the business question of whether all buckets are really needed. For a plain count of unique values, cardinality is the cheaper choice; for complete iteration over all buckets, composite aggregation. Terms aggregation remains the right choice for top-N facets with a moderate size, but loses its efficiency as soon as it is misused for tasks it was not designed for.
Terms Aggregation Performance, the Key Points at a Glance
Check accuracy
doc_count_error_upper_bound and show_term_doc_count_error show whether shard_size is too low.
eager_global_ordinals
Shifts ordinals build from the first query to the refresh cycle, ideal for frequently used facet fields.
High cardinality fields
cardinality instead of terms for plain count questions, composite aggregation for complete iteration.
Circuit breaker
A breaker error is a protection mechanism, reduce size instead of raising the threshold blanket-wide.