skip to content

What are global ordinals in Elasticsearch, and when does eager_global_ordinals pay off?

level: seniorimportance: should knowfreq 45%

answer

  1. Segments number their terms independently
  2. Counting integers beats hashing strings
  3. A shard-wide translation table has to be built
  4. Its cost tracks distinct terms, not matched docs
  5. Refresh throws it away; a mapping flag pre-builds it

basics

~20 s

Global ordinals map each segment's local keyword term numbering onto one shard-wide numbering so terms aggregations can count into integer slots instead of hashing strings. They are rebuilt lazily after each refresh; eager_global_ordinals moves that build into refresh, trading write cost for stable search latency.

solid answer

~50 s

A `keyword` field's doc_values store each distinct term once per segment and reference it by a small integer — its **ordinal**. A `terms` aggregation would love to count into an array indexed by ordinal, but ordinals are per-segment, so the shard first builds a **global ordinal** map that merges every segment's term dictionary into one shard-wide numbering. That build is lazy: it happens on the first aggregation after a refresh, and a refresh invalidates it. Its cost scales with the field's cardinality, so on a high-cardinality field in a frequently-refreshing index some unlucky query pays a long pause over and over. Setting `"eager_global_ordinals": true` on the mapping moves the build into the refresh itself, making search latency predictable at the cost of slower refreshes and lower indexing throughput. It pays off on read-heavy, slowly-changing indices — and is wrong on a hot write path.

code

json · 8 lines
json
{
  "properties": {
    "brand": {
      "type": "keyword",
      "eager_global_ordinals": true
    }
  }
}

go deeper

for a junior

You are unlikely to be asked this. Recall only that keyword aggregations use a compact integer representation of terms and that very high-cardinality fields are expensive to facet on.

for a middle

Explain that ordinals are per-segment and a shard-wide map has to be built to aggregate across segments, that it is cached until refresh, and that its cost follows field cardinality.

for a senior

Demonstrate diagnosis: bimodal latency correlated with refreshes, field data memory on keyword fields, and the choice between eager_global_ordinals, a longer refresh_interval, and execution_hint: map for selective queries.

for a principal

Own the cardinality decision itself. Argue about which identifiers deserve to be facetable at all, how mapping templates encode that policy, and where read-optimized copies of hot data belong relative to the ingest path.

## Ordinals, and why aggregations want them When Elasticsearch writes doc_values for a `keyword` field, it does not repeat the string for every document. Each Lucene segment holds a sorted term dictionary for the field, and each document stores the small integer position of its value in that dictionary. That integer is the **ordinal**. Ordinals are compact, dense, and comparable — sorted ordinal order is sorted term order — which makes them ideal for the counting loop inside a `terms` aggregation: allocate an array of counters, one slot per ordinal, and increment. The catch is that ordinals are **per segment**. Ordinal 7 in one segment and ordinal 7 in another are unrelated strings. A shard-level aggregation spans all its segments, so it needs a translation layer. ## What a global ordinal map is The **global ordinal** map is exactly that layer, built at the shard level: it merges every segment's term dictionary into a single shard-wide numbering and stores, per segment, a mapping from local ordinal to global ordinal. With it in hand, the aggregation can allocate one counter array over the global ordinal space and run its per-document loop as integer arithmetic, only resolving global ordinals back to strings for the handful of buckets that survive into the response. The map's cost is a function of the field's **cardinality** — the number of distinct terms on the shard — not of how many documents match the query. That is the key asymmetry: a query that matches ten documents can still pay for a map covering ten million distinct terms. ## Lazy building, and the refresh cliff Global ordinals are built lazily, on the first request that needs them, and cached on the shard. A refresh exposes new segments and invalidates the map, so the next aggregation rebuilds it. On an index refreshing every second with a high-cardinality field, you get a sawtooth latency profile: most requests are fast, and whichever request lands first after each refresh eats the whole build. That request is not slow because of anything it did — a fact that makes the pattern hard to diagnose from application-side timings alone. The memory the map occupies is accounted for as field data in node stats, so `GET _cat/fielddata?v` on a cluster with heavy keyword aggregations will show significant usage even though nobody enabled `fielddata` on a `text` field anywhere. ## eager_global_ordinals Setting `"eager_global_ordinals": true` on a `keyword` mapping tells Elasticsearch to build the map as part of the refresh, before any search sees the new searcher. Search latency becomes predictable because no query ever pays the build; refreshes become more expensive and, on a write-heavy index, indexing throughput drops because refresh work is on the same critical path. The decision is therefore a ratio question: how often is this field aggregated or used in a `terms`-style lookup, versus how often does the index refresh? Read-heavy, slowly-changing data — a product catalogue faceted on every page load, a reference index refreshed on a schedule — is the good case. A logging data stream ingesting continuously is the bad case: you would pay the build on every refresh and most builds would be wasted. A related detail: `join` fields for parent/child relations enable eager global ordinals by default, because the join implementation cannot work without the map at all and the lazy build would be unavoidable on every query. ## The other lever: execution_hint A `terms` aggregation accepts `"execution_hint": "map"`, which skips global ordinals entirely and builds a hash map from raw term values collected from the matching documents. The tradeoff inverts the asymmetry above: `map` costs in proportion to the number of *matched documents and distinct values found*, not the field's total cardinality. It is the right choice when a very selective query touches a small slice of a very high-cardinality field — for example, a heavily-filtered drill-down on a user ID. Used on a broad query it is worse, because you now hash strings for every hit. The default, `global_ordinals`, is right for the common case. ## Diagnosing it Suspect global ordinals when: the field is `keyword` with high cardinality; latency is bimodal and the slow requests correlate with refreshes; field data memory is high with no `fielddata: true` in the mappings; and a manual refresh followed by an aggregation reproduces the slow case reliably. Remedies in order: reduce cardinality (do you really need to facet on the raw ID?), raise `refresh_interval` on a read-mostly index so the map is rebuilt less often, turn on `eager_global_ordinals` if reads dominate, or switch a selective drill-down query to `execution_hint: map`.

  • Why can a query matching only ten documents still be slow on a high-cardinality keyword field?
    Because the global ordinal map's build cost scales with the number of distinct terms on the shard, not with how many documents the query matched. Ten hits over a field with millions of distinct values still triggers a full map build if the cache was invalidated. `execution_hint: map` avoids exactly this by hashing the values actually seen.
  • When would enabling eager_global_ordinals make things measurably worse?
    On a write-heavy index with a short refresh interval. Every refresh now pays the full build, most of those builds are never used by a query, and refresh sits on the indexing path — so throughput drops and latency spikes move from search to ingest. It only pays when aggregations substantially outnumber refreshes.
  • Does raising refresh_interval help global ordinal cost, and what does it trade away?
    Yes: fewer refreshes mean fewer invalidations, so the cached map is amortized over more queries, and it also produces fewer, larger segments. The trade is search visibility latency — documents become searchable only after the longer interval — which is usually acceptable on read-mostly or batch-loaded indices.

Each segment numbers its own terms the way each chapter of a book might number its own footnotes. Global ordinals are the renumbering pass that gives the whole book one continuous footnote sequence, so you can tally by number instead of by text.

saying these in an interview costs you the question

  • Saying global ordinals are the same thing as doc_values
  • Claiming their cost scales with the number of matching documents
  • Recommending eager_global_ordinals for every keyword field
  • Assuming a merge, not a refresh, invalidates the map
  • Treating field data usage as proof someone enabled fielddata on text

context