An OpenTelemetry histogram is a metric data point that summarizes a distribution of measurements (request durations, payload sizes, queue waits) as counts per value range, plus a total count, sum, and optional min and max. The OpenTelemetry metrics data model defines two encodings: explicit-bucket histograms with hand-picked boundaries, and base-2 exponential histograms that compute their own boundaries and keep relative error bounded without any configuration.
TL;DR
- OpenTelemetry defines two histogram encodings: explicit-bucket, with boundaries chosen at instrumentation time (the SDK default is 15 bounds from 0 to 10,000), and base-2 exponential, with boundaries computed from a scale parameter and no configuration required.
- Exponential histograms use
base = 2^(2^-scale)and rescale automatically; the default of 160 buckets was chosen to cover a 1 ms to 100 s latency range with less than 5% relative error, per the OpenTelemetry project's design analysis. - Percentiles from any bucketed histogram are estimates, not exact values: interpolation inside a bucket can misreport p99 by the full bucket width. In the worked example below, a true p99 of 2.4 s is reported as 1.25 s.
- OTel explicit-bucket histograms map to Prometheus classic histograms; OTel exponential histograms convert directly to Prometheus native histograms, stable since Prometheus v3.8.0.
- At rest, a histogram point is two parallel arrays (boundaries and counts). The alternative design stores raw events and computes quantiles at query time, which removes bucket error entirely.
What is a histogram in OpenTelemetry?
A histogram in OpenTelemetry is an aggregated metric that records how many measurements fell into each of a set of value ranges, called buckets, along with the total count, the sum of all values, and optionally the min and max. It compresses thousands of individual measurements per export interval into one compact, mergeable data point.
The histogram is one of the core instruments in the OpenTelemetry metrics API, alongside the counters and gauges compared in gauge vs counter. Application code calls Record() on a Histogram instrument for every measurement; the SDK aggregates those calls into bucket counts and ships the result over OTLP, OpenTelemetry's wire protocol. Nothing about an individual measurement survives except which bucket it landed in, and every histogram question below follows from that compression.
How do explicit-bucket histograms work?
An explicit-bucket histogram counts observations against a fixed list of boundary values chosen when the instrument is configured. Each measurement increments the count of the bucket whose range contains it. The boundaries never change at runtime, so accuracy depends entirely on how well the chosen boundaries match the data's actual distribution.
The OpenTelemetry SDK specification sets the default boundaries to [0, 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000]: 15 bounds producing 16 buckets, with upper bounds inclusive. Instrument authors override this per metric. The HTTP semantic conventions advise boundaries of [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10] seconds for http.server.request.duration.
An OTLP histogram data point for that metric looks like this (illustrative, abridged):
{
"name": "http.server.request.duration",
"unit": "s",
"histogram": {
"aggregationTemporality": 2,
"dataPoints": [{
"startTimeUnixNano": "1722160800000000000",
"timeUnixNano": "1722160860000000000",
"count": "1000",
"sum": 80.2,
"min": 0.008,
"max": 2.4,
"explicitBounds": [0.005, 0.01, 0.025, 0.05, 0.075, 0.1,
0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10],
"bucketCounts": ["0", "3", "141", "412", "230", "118",
"62", "18", "3", "1", "12", "0", "0", "0", "0"]
}]
}
}bucketCounts always has one more entry than explicitBounds: the final bucket catches everything above the last bound. Here, 1,000 requests were served in the interval, and 12 of them landed in the (1, 2.5] bucket, a slow tail the histogram records but, as shown below, cannot locate precisely.
What is the difference between explicit bucket and exponential histograms?
Explicit-bucket histograms use boundaries a human picked in advance; base-2 exponential histograms compute boundaries from a single scale parameter and adjust it automatically as data arrives. Exponential histograms trade away human-readable round-number boundaries for a guaranteed, predictable relative error at every point in the range, with zero configuration.
In the exponential encoding, bucket boundaries are successive powers of base = 2^(2^-scale). At scale 0 the base is 2, so buckets double. At scale 3 the base is 2^(1/8) ≈ 1.0905, giving 8 buckets per doubling: a 180 ms measurement (recorded as 180) lands in bucket index 59, spanning (2^7.375, 2^7.5] = (165.9, 181.0]. The SDK starts at the configured maximum scale (default 20) and halves the scale, merging adjacent bucket pairs, whenever the observed range would exceed the maximum bucket count (default 160). Values of exactly zero go into a dedicated zero bucket, and negative values get their own mirrored bucket array.
| Dimension | Explicit-bucket | Base-2 exponential |
|---|---|---|
| Boundaries | Hand-picked at instrumentation time | Computed as powers of 2^(2^-scale) |
| Configuration | Per-instrument boundary lists; bad guesses need a redeploy | None; defaults are 160 buckets, max scale 20 |
| Relative error | Unbounded; depends where values land relative to bounds | Predictable; 160 buckets cover 1 ms to 100 s at under 5% |
| Range handling | Fixed; outliers pile into the catch-all last bucket | Rescales automatically to fit the observed range |
| Merging streams | Only when boundary sets match exactly | Always; the higher-scale histogram downscales to match |
| Prometheus mapping | Classic histograms | Native histograms (stable in v3.8.0) |
| Choose when | Boundaries are genuinely known and fixed, such as SLO thresholds, or the backend lacks exponential support | Latency-like distributions with unknown or shifting range; the sensible default |
How do you calculate percentiles from OpenTelemetry histograms?
Percentiles are estimated by walking the buckets in order, accumulating counts until the target rank is reached, then interpolating a value inside that bucket, typically linearly. The result is an approximation whose error is bounded by the width of the bucket the percentile falls in, which is why bucket layout matters so much.
In the data point above, p99 of 1,000 requests is the 990th sorted value. Cumulative counts reach 988 at the 1 s bound and 1,000 at 2.5 s, so p99 falls in (1, 2.5]. Linear interpolation, the method Prometheus's histogram_quantile() uses, reports 1 + (990 - 988)/12 × 1.5 = 1.25 s. If all 12 slow requests actually took 2.4 s, the true p99 is 2.4 s: the estimate understates reality by 48%, and nothing at query time can recover the difference because the raw values are gone. Exponential histograms shrink this uncertainty to a fixed relative error but never eliminate it.
What bucketed estimates do preserve is mergeability across hosts and time windows, something finished percentile values lack because percentiles cannot be averaged; quantile sketches such as t-digest and DDSketch make the same trade with adaptive structure instead of fixed buckets.
How do OpenTelemetry histograms compare to Prometheus histograms?
The two models correspond almost one-to-one. An OpenTelemetry explicit-bucket histogram maps to a Prometheus classic histogram, and an OpenTelemetry exponential histogram maps to a Prometheus native histogram, which uses the same base-2 bucketing scheme. Prometheus summaries have no OpenTelemetry equivalent. The main remaining mismatch between the two systems is temporality.
In the classic Prometheus format, each bucket is exposed as a cumulative counter series with an le label. Native histograms fold the buckets into a single sample; they shipped as an experimental feature in Prometheus v2.40 (November 2022) and became stable in v3.8.0, and OTel exponential histograms convert to them directly. Summaries, client-side quantiles that cannot be aggregated across instances, have no producing instrument in OpenTelemetry; the data model carries a Summary type only for compatibility with legacy sources.
Prometheus is cumulative-only, with every scrape reporting totals since process start. OTLP supports both cumulative and delta temporality, where each export covers only the interval since the previous one. Delta streams keep collector memory flat and suit event-style backends, but they must be re-accumulated before Prometheus can ingest them, and a dropped delta point is data lost forever rather than recovered at the next scrape. Most SDKs default to cumulative; check what your backend expects before overriding it.
How are OpenTelemetry histograms stored in a database?
At rest, a histogram data point is a row holding two parallel arrays, one of bucket boundaries and one of bucket counts, plus scalar columns for count, sum, min, max, and temporality. Exponential points store a scale, a zero count, and offset-indexed count arrays instead of explicit bounds. Columnar databases hold these arrays natively in a single row.
Storage layout diverges sharply by backend family. Prometheus's classic format explodes each histogram into separate time series per bucket: the 14-bound HTTP histogram above becomes 15 _bucket series plus _count and _sum for every label combination, a large share of histogram cardinality cost. Native histograms fold all buckets back into one series. Columnar stores keep the arrays in one row per data point and unnest them at query time.
There is also a second design entirely: skip pre-aggregation and store every raw measurement as a wide event. Raw events cost more to ingest and store, but quantiles computed over them are exact for any filter, group, or time window, with no bucket-boundary decisions and no interpolation error. Which side of that trade-off wins depends on whether the histogram's compression or the raw data's flexibility matters more for the question being asked.
How ClickStack stores and queries OpenTelemetry histograms
ClickStack, the ClickHouse-based observability stack, ingests metrics through the OpenTelemetry Collector, whose ClickHouse exporter writes histogram points with the array layout described above: ExplicitBounds Array(Float64) and BucketCounts Array(UInt64) columns alongside AggregationTemporality, count, sum, min, and max. A histogram query is ordinary SQL over arrays (illustrative, simplified schema):
SELECT
TimeUnix,
ExplicitBounds, -- [0.005, 0.01, ..., 10]
BucketCounts -- one count per bucket
FROM otel_metrics_histogram
WHERE MetricName = 'http.server.request.duration'
ORDER BY TimeUnix DESCBecause ClickStack also keeps raw traces and logs as wide rows, the exact-quantile lane is available in the same database: quantileTDigest() or quantileExact() over span durations returns percentiles free of bucket error, for any ad-hoc slice. HyperDX, ClickStack's UI, charts both forms. ClickStack is open source; the getting started guide covers the collector setup and schemas these queries run against.
FAQ
How do I record a histogram metric with OpenTelemetry?
Create a Histogram instrument from a Meter and call its record method once per measurement: histogram.Record(ctx, value, attributes) in Go, or histogram.record(value, attributes) in Python and JavaScript. The SDK aggregates the calls into buckets; a View or the instrument's advisory parameters selects explicit-bucket or exponential aggregation and custom boundaries.
How do I choose histogram bucket boundaries?
Start from the semantic conventions' advice for the metric; HTTP server duration recommends 14 bounds from 5 ms to 10 s. Ensure a boundary sits at each SLO threshold you alert on, since accuracy is best at the bounds. If the value range is unknown or shifting, use the exponential aggregation and skip the guessing entirely.
Should I use delta or cumulative temporality?
Match your backend. Prometheus and PromQL-compatible stores require cumulative, which is also most SDKs' default and self-heals after dropped points. Delta reduces memory in collectors and suits backends that treat each interval as an independent event, but lost delta points are unrecoverable. Check the backend's documentation before changing the SDK default.
Can you calculate an exact p99 from an OpenTelemetry histogram?
No. A histogram only knows which bucket each measurement landed in, so any percentile is an interpolated estimate bounded by that bucket's width. Exact percentiles require the raw measurements, computed with order statistics or an exact quantile function at query time.