Choosing Histogram Buckets for Latency SLOs

A latency histogram answers exactly the questions its bucket boundaries allow. Get one boundary right and the SLO compliance figure is an exact count; get it wrong and the same query returns an interpolation whose error nobody can see. This page covers choosing the ladder, sizing it against the real distribution, and the cardinality budget it consumes. It builds on metric types and cardinality, part of the Python metrics and instrumentation section.

A boundary on the objective turns an estimate into a count One latency distribution drawn twice with different bucket ladders and an SLO threshold of two hundred and fifty milliseconds marked on both. In the first ladder the nearest boundaries are one hundred and five hundred milliseconds, so the threshold falls inside a wide bucket: answering what proportion of requests were faster than two hundred and fifty milliseconds requires assuming the observations inside that bucket are spread evenly across it, which they are not, and the resulting compliance figure can be several percentage points away from the truth in either direction with no indication that it is estimated at all. In the second ladder a boundary sits exactly at two hundred and fifty milliseconds, so the same question is answered by dividing one bucket count by the total: an exact number, computed from counters, with no interpolation anywhere in it. The rest of the ladder is unchanged, so the cost of the improvement is one extra series per label combination. SLO: 99% of requests under 250 ms boundaries at 100 and 500 100 500 250 — the objective, in the middle of a bucket the answer is an interpolation across a range where the curve is steep — and it does not say so a boundary at 250 250 — a boundary, so the count is exact compliance = bucket{le="0.25"} / count — counters divided by counters, no estimate anywhere · cost: one series
The distribution is identical. One ladder answers the SLO question exactly and the other estimates it, for the price of a single extra boundary.

Prerequisites

pip install "prometheus-client>=0.20.0,<1.0.0" \
            "opentelemetry-sdk>=1.27.0,<2.0.0"

Implementation

Step 1 — Measure the distribution before choosing a ladder. A ladder chosen from assumptions usually puts most observations in one bucket, which is a counter with extra storage cost.

import time
from statistics import quantiles

samples = []
for _ in range(2000):
    started = time.perf_counter()
    handle_request()
    samples.append(time.perf_counter() - started)

p50, p90, p99 = quantiles(samples, n=100)[49], quantiles(samples, n=100)[89], quantiles(samples, n=100)[98]
print(f"p50 {p50*1000:.1f} ms · p90 {p90*1000:.1f} ms · p99 {p99*1000:.1f} ms")

Expected Output:

p50 41.2 ms · p90 138.6 ms · p99 412.9 ms

Step 2 — Build the ladder around three anchors. The SLO threshold, the observed median, and roughly twice the observed p99 so the tail is bracketed rather than dumped in +Inf.

from prometheus_client import Histogram

REQUEST_LATENCY = Histogram(
    "http_request_duration_seconds",
    "Request latency in seconds",
    ["route", "method"],
    buckets=(
        0.010, 0.025, 0.050,      # around and below the p50 of 41 ms
        0.100, 0.150,             # the p90 region
        0.250,                    # the SLO objective — an exact boundary
        0.400, 0.600,             # around the p99 of 413 ms
        1.000, 2.500,             # the tail, bracketed
    ),
)

Ten boundaries. Every one of them earns its place: three describe the body of the distribution, one is the objective, two bracket the p99, and two catch the tail.

Step 3 — Write the SLO query against the boundary.

# exact: two counters divided by each other
sum(rate(http_request_duration_seconds_bucket{le="0.25"}[30d]))
  /
sum(rate(http_request_duration_seconds_count[30d]))

No histogram_quantile, no interpolation. The compliance figure is as exact as the counters themselves, which is what an SLO report should be.

Step 4 — Budget the series. Buckets multiply with labels, and the product is the number that appears on a storage bill.

Ladder Boundaries Routes Methods Series
Default prometheus_client 15 12 3 612
The ladder above 10 12 3 432
Same, plus a status label 10 12 3 × 5 2 160
Same, plus tenant (200) 10 12 3 86 400

The last row is why request identity belongs in an exemplar rather than a label, as set out in controlling label cardinality in Prometheus.

Too coarse, too fine, matched The same latency distribution under three bucket ladders. The coarse ladder has four boundaries spread over three orders of magnitude, so nearly every observation falls into one bucket: the histogram carries almost no information beyond what a counter would, and any quantile query interpolates across a bucket wider than the entire distribution. The fine ladder has forty closely spaced boundaries covering the same range, which resolves the distribution beautifully and costs forty series per label combination — most of them describing regions where no request has ever landed, and all of them stored, scraped and queried forever. The matched ladder has ten boundaries positioned by the observed percentiles and the SLO threshold: the body of the distribution is resolved, the objective is exact, the tail is bracketed, and the series count is one quarter of the fine version. The note added is that the fine ladder is usually chosen defensively, and the defence costs more than the risk. the same distribution, three ladders too coarse nearly everything in one bucket — a counter with extra cost too fine 40 series per label combination, most describing regions nothing lands in matched the thick one is the objective — body resolved, tail bracketed, a quarter of the series
The fine ladder is usually chosen defensively. The defence costs four times the storage and answers no question the matched ladder cannot.

Step 5 — Give each metric family its own ladder. A cache lookup, a web request and a nightly export have nothing in common distributionally.

CACHE_LATENCY = Histogram(
    "cache_operation_duration_seconds", "Cache latency", ["operation"],
    buckets=(0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01, 0.05),
)

EXPORT_DURATION = Histogram(
    "report_export_duration_seconds", "Report export duration", ["report"],
    buckets=(1.0, 5.0, 15.0, 30.0, 60.0, 120.0, 300.0, 600.0),
)

For OpenTelemetry, the same ladder is expressed as a View, which is covered in configuring views and aggregation in OpenTelemetry metrics.

What a quantile in a wide bucket is really telling you A histogram whose upper buckets are widely spaced, with a p99 query landing inside the bucket that spans one to ten seconds. The query returns a single number, computed by assuming the observations inside that bucket are spread evenly across it, and the returned value sits near the lower end. In reality the observations could be clustered anywhere in that range: near the bottom, in which case the reported number is roughly right, or near the top, in which case the true p99 is several times larger than reported. Nothing in the returned value indicates which, and the number is displayed with the same precision either way. The remedy shown is to add boundaries inside the range where the tail actually lives — two or three extra series — after which the same query returns a value whose uncertainty is bounded by the width of a much narrower bucket. histogram_quantile(0.99, …) = 1.42 s — or is it? 0.5 1 10 one bucket, 1 s to 10 s the reported 1.42 s where the true p99 could actually be — the query does not say the remedy is two or three boundaries, not a different query add 1.5, 2.5 and 5 — the same query then returns a number whose uncertainty is the width of a much narrower bucket
The query returns one number with full precision regardless. The interpolation error is invisible in the result and bounded only by the bucket's width.

Configuration options

Decision Guidance
Boundary on the SLO threshold mandatory — it is the whole point
Ladder range from below the p50 to about 2× the p99
Boundary count 10–15
Growth roughly geometric between anchors
Per family a separate ladder for each distribution
+Inf implicit; a full +Inf bucket means the ladder is too short
Labels budgeted as boundaries × label combinations

Verification

Confirm the SLO query against a directly counted number.

from prometheus_client import REGISTRY

under_slo = REGISTRY.get_sample_value(
    "http_request_duration_seconds_bucket",
    {"route": "/orders/{id}", "method": "GET", "le": "0.25"},
)
total = REGISTRY.get_sample_value(
    "http_request_duration_seconds_count", {"route": "/orders/{id}", "method": "GET"},
)
print(f"compliance {under_slo / total:.4%}")

Expected Output:

compliance 99.2140%

Then compare that against a count computed from the raw samples in the same run. They must match exactly — if they differ, the boundary is not where you think it is, most often because the le label is formatted differently from the literal you wrote (0.25 versus 0.250).

Also check that the top bucket is not saturated:

rate(http_request_duration_seconds_bucket{le="+Inf"}[5m])
  - ignoring(le) rate(http_request_duration_seconds_bucket{le="2.5"}[5m])

A non-trivial value means requests are landing beyond the ladder, and every quantile above that point is a guess.

Common mistakes

The SLO number moves when nobody changed anything

Error signature: monthly compliance shifts by a percentage point between reports with no code or traffic change. Root cause: the objective sits between boundaries, so the figure is an interpolation and moves as the distribution's shape inside that bucket shifts. Remediation: add a boundary at exactly the threshold and compute the ratio from counters.

Every observation is in the first bucket

Error signature: all bucket counts are equal, and quantile queries return the lowest boundary. Root cause: the ladder was copied from a metric with a different distribution — usually the library default applied to a sub-millisecond operation. Remediation: measure the distribution and build the ladder around its percentiles.

One histogram is most of the storage bill

Error signature: a single metric name accounts for the majority of series. Root cause: boundaries multiplied by a high-cardinality label. Remediation: cut boundaries first, then labels. Move request identity to an exemplar — see linking metrics to traces with exemplars.

Changing a ladder that is already deployed

Bucket boundaries are part of a metric's identity in Prometheus — each one is its own series — so changing them is not a transparent operation, and doing it carelessly breaks the queries that motivated the change.

What happens. Old boundaries stop receiving data and new ones start from zero. A histogram_quantile query spanning the change reads both sets and produces a result that is wrong in an interesting way: the old buckets have counts and no recent increase, the new ones have a partial history. Rate queries over a window shorter than the time since the change are fine; anything longer is not.

The safe sequence. Add boundaries without removing any, wait out the longest query window you care about, then remove the ones you do not want. Adding is purely additive — new series appear, old ones keep working — and the intermediate state is queryable throughout. Removing afterwards is a cleanup rather than a change.

When the whole ladder must change, for example moving from a millisecond-scale to a second-scale distribution, a new metric name is cleaner than mutating the existing one. Dashboards move at their own pace, the old series ages out with retention, and no query ever spans two incompatible ladders.

Change Method Query impact
Add a boundary deploy it none; new series start empty
Remove a boundary after the longest query window none, if the wait is respected
Reshape the whole ladder a new metric name dashboards migrate deliberately
Change the unit a new metric name, always otherwise silently wrong numbers

That last row deserves emphasis: changing a histogram from milliseconds to seconds without renaming it produces a metric whose historical values are a thousand times off and whose queries return plausible nonsense. The unit belongs in the name — _seconds — precisely so that this mistake is visible in review.

Native and exponential histograms

Both Prometheus and OpenTelemetry now offer a form of histogram whose buckets are generated from a scale factor rather than enumerated: exponential-bucket histograms in OpenTelemetry, native histograms in Prometheus. They remove the bucket-choosing problem entirely — resolution is uniform in relative terms across the whole range — and they are attractive for exactly the metrics where nobody knows the distribution in advance.

The trade for an SLO metric is specific: you cannot place a boundary exactly on the objective, because the boundaries are generated. A compliance figure from an exponential histogram is therefore an interpolation, with an error bounded by the bucket width at that point — small at a reasonable scale, and not zero.

The practical position that follows is a split. Use exponential or native histograms for exploratory and general-purpose latency metrics, where the freedom from bucket choice is worth an interpolation nobody is auditing. Keep explicit buckets for the handful of metrics that back an SLO, where the exact count is the point. Two mechanisms, chosen per metric, rather than one applied everywhere.

A checklist before shipping a new histogram

Five questions, all answerable in a few minutes, and between them they prevent every failure described on this page. What is the distribution — measured, not assumed? Where is the objective, and is there a boundary on it? How many series does this create, boundaries multiplied by label combinations? Is the unit in the name? And is the top boundary high enough that the +Inf bucket is not routinely receiving traffic? A histogram that answers all five is one nobody has to revisit.

Frequently Asked Questions

Why does the exact bucket boundary matter for an SLO?

Because a bucket count is exact and everything between boundaries is estimated. If the objective is 250 milliseconds and there is a boundary at 250, the number of requests that met it is a direct count with no error at all. If the nearest boundaries are 100 and 500, the answer is a linear interpolation across a range where the real distribution is anything but linear, and the reported compliance can be off by several percentage points in either direction.

How many buckets is too many?

Each boundary is one series for each label combination, so a histogram with fifteen boundaries and four route values is sixty bucket series plus sum and count. That is comfortable. The same histogram with fifty boundaries and forty routes is two thousand series for one metric, which is where histograms start to dominate a storage bill. Ten to fifteen boundaries covering the range that matters beats a fine-grained ladder covering ranges nobody queries.

Should I use the client library's default buckets?

As a starting point only. The prometheus_client default ladder runs from 5 milliseconds to 10 seconds, which suits a general web request and is wrong for a cache lookup that completes in 200 microseconds and equally wrong for a report export that takes 40 seconds. Both end up with every observation in one bucket, which is a count with extra steps.

What does histogram_quantile actually compute?

It finds the bucket containing the requested quantile and interpolates linearly within it, assuming observations are evenly spread across that bucket — which they are not. The estimate is good when the bucket is narrow relative to the spread and poor when it is wide, which is why a p99 that lands in a bucket spanning 1 to 10 seconds should be treated as 'somewhere in that range' rather than as a number.

Do OpenTelemetry and Prometheus buckets work the same way?

The concept is the same, and OpenTelemetry adds explicit-bucket histograms configured through a View plus an exponential-bucket variant that adapts its boundaries automatically. The exponential form removes the bucket-choosing problem at the cost of not letting you place a boundary exactly on an SLO threshold, so an SLO-critical metric is usually still better served by explicit buckets.