Query histogram fields in ES|QL

Histogram fields store pre-aggregated value distributions rather than individual data points. They are useful anywhere you have high-volume numeric data and need percentile, average, or count queries without the storage cost of keeping every raw value. The most common source is observability pipelines: an OpenTelemetry agent can record thousands of HTTP request durations per minute and ship them as a single exponential histogram per collection interval.

This page explains the histogram field types available in ES|QL, how to query them, and how to create value-distribution histograms from them.

When you need to understand the distribution of values across a large population, storing every individual observation is often impractical. A histogram compresses those observations into a compact summary that still supports percentile queries, averages, and counts. This makes histograms useful anywhere you have high-volume numeric data and care about the shape of the distribution, not just a single aggregate.

For example, knowing that your average HTTP response time is 3ms tells you little about outliers. A histogram preserves enough detail to answer questions like "what is the 99th percentile?" without storing every request.

Histograms are widely used in metrics pipelines. OpenTelemetry and Prometheus both define exponential histogram formats that dynamically adapt bucket boundaries to the data, giving a guaranteed upper bound on relative error for every percentile. Classic Prometheus-style histograms use fixed buckets instead, which requires knowing the value distribution up front.

For a deeper explanation of how exponential bucketing works, refer to the OpenTelemetry exponential histograms introduction.

ES|QL recognizes three histogram field types:

exponential_histogram
The recommended type for new data. Uses an exponential bucketing scheme that dynamically adapts to your data and provides a guaranteed upper bound on relative error for every percentile. This is the native format used by OpenTelemetry exponential histograms and maps directly to Prometheus native histograms. Most aggregation functions work natively with this type.
tdigest
Stores a T-Digest data structure. Provides good accuracy for extreme percentiles (like p99) but loses accuracy for mid-range percentiles like the median. Before Elasticsearch 9.4, OpenTelemetry histograms were converted to T-Digest for storage. Use this type when your data is already stored as T-Digest values.
histogram (legacy)
The original histogram field type. This type can represent either T-Digest or HDR histogram data, but ES|QL only supports T-Digest. Cast histogram fields with ::tdigest to query them, or with ::exponential_histogram to convert them for use alongside newer data. Refer to Cast between histogram types.

To check which type a metric uses, run METRICS_INFO against the data stream.

For new data, use exponential_histogram. It provides a guaranteed upper bound on relative error for every percentile and eliminates the lossy conversion step that T-Digest requires. Percentile computation on exponential histograms is also more efficient at query time.

Use tdigest only when your data is already stored in that format. T-Digest provides good accuracy at the tails of the distribution (p99, p99.9) but lower accuracy for mid-range percentiles like the median. It also only supports delta temporality (not cumulative).

Apply regular aggregation functions directly to histogram fields. Aggregations act as if you were running them on the raw observations that produced the histogram. For example, COUNT(responseTime) returns the total number of HTTP requests whose response times were recorded, not the number of histogram documents. The following functions support histogram inputs:

Function What it returns for histogram fields
COUNT Total number of values recorded across all histograms
SUM Sum of all recorded values
AVG Average of all recorded values (computed as SUM / COUNT)
MIN Minimum recorded value
MAX Maximum recorded value
MEDIAN Estimated median (50th percentile)
PERCENTILE Estimated value at a given percentile
FIRST / EARLIEST First histogram value by sort order / timestamp
LAST / LATEST Last histogram value by sort order / timestamp

For example, to calculate the count, average, and 99th-percentile duration of garbage collection events per action:

FROM metrics-*
| STATS count = COUNT(jvm.gc.duration),
        avg = AVG(jvm.gc.duration),
        p99 = PERCENTILE(jvm.gc.duration, 99)
  BY jvm.gc.action
		

Because PERCENTILE works on the histogram directly, you can query any percentile at runtime without having to pre-define bucket boundaries at index time. This is a key advantage of the exponential_histogram type over classic fixed-bucket approaches.

TS is the recommended source command for time series data. Before your aggregation runs, TS performs an implicit per-series merge: all histogram documents within each time bucket and series are combined into a single histogram, and the metric's temporality (delta or cumulative) is respected during the merge. The outer aggregation then operates on these merged per-series histograms.

You do not need a time series aggregation function like RATE or AVG_OVER_TIME to query histogram fields. Use the regular aggregation functions from the table above, combined with TBUCKET for time-based grouping:

TS metrics-*
| WHERE TRANGE(1 hour)
| STATS count = COUNT(jvm.gc.duration),
        avg = AVG(jvm.gc.duration),
        p99 = PERCENTILE(jvm.gc.duration, 99)
  BY jvm.gc.action, TBUCKET(5 minutes)
		

The time series aggregation functions (*_OVER_TIME variants) also accept histogram inputs for windowed aggregation within a time series. You can use these when you need finer control over the aggregation window, for example FIRST_OVER_TIME or LAST_OVER_TIME to select the histogram from a specific point in the time series. For most use cases, the regular aggregation functions are sufficient.

FROM also supports histogram aggregations. Unlike TS, FROM does not perform an implicit per-series merge or handle metric temporality.

If your data is stored in a time series data stream, use TS. Use FROM for non-TSDS data such as legacy indices or non-metrics use cases.

FROM metrics-*
| STATS count = COUNT(responseTime),
        avg = AVG(responseTime),
        p99 = PERCENTILE(responseTime, 99)
  BY instance
		

To break down the values recorded in a histogram field into fixed-width buckets, combine BUCKET with COUNT.

When applied to a histogram field, BUCKET returns double_range buckets instead of single values. A histogram that spans several buckets contributes a row to each of them. Pass the bucket as the second argument to COUNT to count the histogram values that fall into each bucket:

FROM exp_histo_sample
| WHERE instance == "instance-0"
| STATS count = COUNT(responseTime, bucket) BY bucket = BUCKET(responseTime, 1)
| SORT RANGE_MIN(bucket)
		
count:long bucket:double_range
8723 0.0..1.0
112 1.0..2.0
1 2.0..3.0
2 3.0..4.0
2 5.0..6.0
1 6.0..7.0

Use RANGE_MIN or RANGE_MAX to extract the start or end of each double_range bucket for sorting or further computation.

Note

Histograms record approximate value distributions, so the counts per bucket are estimates.

The same pattern works for tdigest fields. Cast the field if needed:

FROM histogram_timeseries_index
| WHERE instance == "instance-0"
| STATS count = COUNT(responseTime::tdigest, bucket) BY bucket = BUCKET(responseTime::tdigest, 1)
| SORT RANGE_MIN(bucket)
		
count:long bucket:double_range
8733 0.0..1.0
100 1.0..2.0
4 2.0..3.0
2 3.0..4.0
0 4.0..5.0
0 5.0..6.0
2 6.0..7.0

A T-Digest does not track which ranges between its centroids are empty. BUCKET returns every bucket between the smallest and the largest centroid, so some buckets may show a count of 0. Exponential histograms skip empty buckets.

Use the casting operator (::) to convert between histogram types inline:

  • field::exponential_histogram converts to an exponential histogram. This is the recommended default because exponential_histogram is the native type used for newly ingested metrics.
  • field::tdigest converts to a T-Digest. Use this when you know the data was originally stored as T-Digest centroids.
FROM metrics-*
| STATS avg = AVG(response_time::exponential_histogram) BY instance
		

You can also use the explicit conversion functions TO_EXPONENTIAL_HISTOGRAM and TO_TDIGEST in an EVAL step. Both functions accept all three histogram types as input and return the target type (identity conversion is a no-op).

Before Elasticsearch 9.4, OpenTelemetry histograms were converted to T-Digest for storage in the histogram field type. After upgrading, newer indices use exponential_histogram while older indices still contain histogram data.

Thanks to union types, you can query across both by adding a ::exponential_histogram cast:

FROM metrics-*
| STATS avg = AVG(jvm.gc.duration::exponential_histogram) BY jvm.gc.action
		

When this query encounters histogram fields, it converts them to exponential histograms. When it encounters exponential_histogram fields, the cast has no effect. If you are building queries or dashboards that may run on pre-9.4 data, adding ::exponential_histogram casts is recommended.

Use METRICS_INFO to inspect which field types are in use across backing indices.

  • Sorting on histogram fields is not allowed. Use SORT on aggregated results (like RANGE_MIN(bucket)) instead of on the histogram field itself.
  • RATE does not accept histogram fields. Use other aggregation functions on the histogram directly.
  • VALUES does not accept histogram types.
  • Multivalue functions like MV_FIRST, MV_LAST, and MV_COUNT reject histogram fields.
  • Counts and percentiles derived from histogram fields are estimates because the underlying data structures store distributions, not exact values.