Why you cannot average percentiles
What it is
A percentile is a quantile of a distribution, and quantiles do not combine linearly. Averaging the p99 values reported by ten instances does not give you the fleet's p99, and the error is not small.
Instance A: p99 = 100 ms, serving 1,000 requests
Instance B: p99 = 100 ms, serving 10 requests
mean(p99) = 100 ms
The fleet's true p99 is dominated by A's distribution, because A
has 99% of the requests, and B contributes almost nothing. Here
they happen to agree, and that is luck rather than arithmetic.
The failure is much larger when the distributions differ:
Instance A: 1,000 requests, all at 10 ms. p99 = 10 ms
Instance B: 10 requests, all at 5,000 ms. p99 = 5,000 ms
mean(p99) = 2,505 ms
TRUE fleet p99: sort all 1,010 requests, take the 999th.
1,000 requests at 10 ms, then 10 at 5,000 ms.
The 999th value is 10 ms.
*** True p99 = 10 ms. The average said 2,505 ms. ***
Off by 250x, and in the direction that makes you chase a
problem that does not exist.
Commonly confused with a precision issue. It is not approximation error; it is a category error. A percentile is a property of a distribution, and you cannot recover a distribution's quantile from other distributions' quantiles without the underlying data.
Also commonly confused with the related but distinct fact that percentiles cannot be averaged over time either. The mean of 24 hourly p99 values is not the day's p99, for the same reason.
The problem it solves
Every monitoring system makes this easy to do wrong, and the resulting numbers are used for SLOs, capacity decisions and incident triage.
Prometheus:
avg(http_request_duration_p99) WRONG, silently
avg by (service) (rate(...)) fine, rates DO average
Datadog / New Relic / CloudWatch:
A p99 metric aggregated across hosts is often a mean of
per-host p99s unless you configure otherwise.
CloudWatch specifically:
Percentile statistics on a metric with multiple dimensions
are computed per dimension, and the dashboard's aggregation
across them is not a true percentile.
The consequences are practical rather than academic:
Under-reporting You believe the p99 is 80 ms; users experience
400 ms. The SLO says you are fine and support
tickets say you are not.
Over-reporting You chase a phantom regression caused by one
low-traffic instance, as in the example above.
Wrong triage You cannot tell whether one instance is slow or
all of them are, which is the first question in
a latency incident.
Mechanics
Why the arithmetic fails
A percentile is defined by ORDER over the full sample.
p99 = the value below which 99% of observations fall.
To compute it for a union of sets, you need the union's
ORDERING, which requires either the raw values or a
structure that preserves enough distributional information.
Per-instance p99 values discard exactly that: each is a
single number summarising a distribution, and the summary
is not composable.
Nor are percentiles averageable over time, which is the same fact in a different direction:
Hour 1: p99 = 100 ms over 1M requests
Hour 2: p99 = 900 ms over 1K requests (a deploy, low traffic)
mean = 500 ms.
True daily p99: the slow hour is 0.1% of requests, so it
barely moves the 99th percentile of the day. True p99 ≈ 105 ms.
What to do instead: histograms
The correct approach is to aggregate the distribution, not the summary.
Each instance exports a HISTOGRAM: bucket counts, not a
percentile.
le=0.005 count= 120
le=0.010 count= 3,400
le=0.025 count= 8,900
le=0.050 count= 9,700
le=0.100 count= 9,950
le=0.250 count= 9,990
le=+Inf count=10,000
Bucket counts ARE additive. Sum them across instances, then
compute the percentile from the summed histogram.
# CORRECT. Sum the bucket counters across instances first,
# then compute the quantile from the aggregate distribution.
histogram_quantile(0.99,
sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)
# WRONG, and it is the query people write.
avg(http_request_duration_seconds_p99)
The sum by (le) is the entire correction, and it works because the histogram's bucket
counters are counts, and counts add.
The cost of histograms, and the accuracy trade
Histograms are not free and their accuracy is bounded by bucket layout.
Prometheus classic histograms:
One time series PER BUCKET, per label combination.
A 12-bucket histogram with 5 label values on 3 dimensions
is 12 x 5^3 = 1,500 series for one metric.
-> This is the cardinality cost model, and it is why
histogram bucket count is a real decision.
Accuracy:
histogram_quantile INTERPOLATES LINEARLY within the bucket
containing the target quantile. If p99 falls in the bucket
[0.1, 0.25], the answer is somewhere in that range and the
interpolation assumes a uniform distribution inside it,
which is usually false.
*** Your p99 is only as accurate as your bucket boundaries
near the p99. ***
Which produces the practical rule: put bucket boundaries where you care.
DEFAULT (Prometheus): .005 .01 .025 .05 .1 .25 .5 1 2.5 5 10
Good for a 100 ms service; useless for a 3 ms one, where
everything lands in the first bucket and the p99 is
interpolated across [0, 0.005].
TUNED for a service with a 300 ms SLO:
.05 .1 .15 .2 .25 .3 .35 .4 .5 .75 1 2.5
Dense around the SLO threshold, sparse elsewhere.
Native histograms (Prometheus 2.40+, experimental then stable) change this: exponential buckets with a configurable resolution, stored as one series rather than one per bucket, giving much better accuracy at a fraction of the cardinality. Naming them is a good signal that you have followed the tooling.
The alternative: t-digest and DDSketch
Sketch data structures that ARE mergeable and give bounded
RELATIVE error at the tails, which is where percentiles matter.
t-digest Relative error is smallest at the extremes,
which is exactly where you want it for p99 and
p999. Used by Elasticsearch's percentile
aggregation and by many APM vendors.
DDSketch Guarantees relative error at ANY quantile, and
is fully mergeable. Used by Datadog.
The trade against histograms: sketches give better tail accuracy without needing bucket boundaries tuned per service, at the cost of a more complex data structure and a non-standard wire format. For a fleet with heterogeneous latency profiles, sketches avoid the per-service bucket-tuning problem entirely, which is a real operational advantage.
The related error: percentile of a percentile
"Our p99 latency is 200 ms" for a request that fans out to 5
backends, each with a p99 of 200 ms.
The request waits for the SLOWEST backend, so its latency is
the MAXIMUM of 5 samples, not one sample.
P(all 5 under their p99) = 0.99^5 = 0.951
-> 4.9% of requests have at least one backend above its p99.
-> The request's p99 corresponds to roughly each backend's
p99.8, not its p99.
This is the tail-at-scale result and it compounds with the averaging error: a fleet whose reported p99 is already wrong is then used to reason about a fan-out that makes the true tail worse still. See the tail at scale.
What you can and cannot aggregate
CAN AGGREGATE ACROSS INSTANCES
counters sum
rates sum (they are counters per second)
histogram buckets sum
means weighted by count
min / max min / max
CANNOT
percentiles no
medians no (a median IS a percentile)
standard deviations not without sums of squares
unique counts no (need HyperLogLog or the raw set)
"Means can be aggregated if weighted by count" is worth stating, because it is the one exception people assume applies to percentiles too. A mean is a sum divided by a count, and both of those are additive; a percentile is an order statistic, and order is not.
A worked example: an incident that was not
ALERT: "API p99 latency 2.4 s, threshold 500 ms."
Dashboard: avg(api_latency_p99) across 40 pods.
INVESTIGATION
Per-pod p99, sorted:
pod-31 41,200 ms <--
pod-07 210 ms
pod-12 195 ms
... 37 more pods, all 180-260 ms
avg of 40 values with one at 41,200:
(41,200 + 39 x ~210) / 40 = 1,235 ms
The dashboard's higher number came from a shorter window
and more extreme skew.
TRUE fleet p99, from summed histogram buckets: 268 ms.
WHAT WAS ACTUALLY HAPPENING
pod-31 had been serving 3 requests per minute since a
readiness probe flap, and those 3 requests were long-poll
connections. Its p99 was computed over 3 samples and was
meaningless.
The fleet was healthy. The alert was arithmetic.
THE FIXES
1. Alert on histogram_quantile over SUMMED buckets, never
on an average of per-instance percentiles.
2. Suppress percentile computation below a minimum sample
count. A p99 over 3 samples is not a p99.
3. Alert SEPARATELY on per-pod outliers, because "one pod
is slow" is a real and different signal that the fleet
aggregate correctly hides.
Point 3 is the subtlety worth stating: the correct fleet percentile deliberately hides a single bad instance, which is right for an SLO and wrong for triage. You need both signals, computed differently, and conflating them is what produced the incident.
Production evidence
Prometheus's histogram_quantile documentation states explicitly that you must aggregate
the bucket counters with sum by (le) before computing the quantile, and warns about
interpolation error within buckets.
Prometheus native histograms (2.40 onward) were introduced specifically to address the cardinality cost of classic bucketed histograms and their fixed-boundary accuracy problem, which is direct evidence that both were real operational limits.
Dunning and Ertl's t-digest paper documents the mergeable-sketch approach with relative error smallest at the extremes, and Elasticsearch's percentile aggregation uses it, with documentation noting the accuracy characteristics.
Datadog's DDSketch paper (Masson, Rim and Lee, VLDB 2019) gives a mergeable sketch with a relative-error guarantee at any quantile, and their write-ups on why averaging percentiles is wrong are among the clearest public explanations.
Gil Tene's "How NOT to Measure Latency" talk is the canonical treatment of percentile misuse, including coordinated omission, which is a related and equally serious measurement error where a load generator stops sending during a stall and therefore fails to record the worst latencies at all.
AWS CloudWatch's documentation on percentile statistics notes that percentiles are computed per-metric and that cross-dimension aggregation does not produce a true percentile, which is the vendor stating the problem.
The debate
The case for histograms everywhere: correct aggregation, standard tooling, and bucket counters are cheap counters. The interpolation error is bounded and manageable if the buckets are placed sensibly.
The case for sketches (t-digest, DDSketch): better tail accuracy with a relative-error guarantee, no per-service bucket tuning, and full mergeability. For a heterogeneous fleet where every service has a different latency profile, tuning bucket boundaries per service is work that sketches eliminate.
The case for just using averages: they aggregate correctly, they are cheap, and for many purposes the mean is a fine signal. It is also true that the mean hides exactly the tail behaviour that percentiles exist to expose, so it is not a substitute.
My position: histograms with buckets tuned around the SLO threshold, aggregated with
sum by (le), plus a separate per-instance outlier alert.
The correction itself is one clause, sum by (le) before histogram_quantile, and getting it
wrong is silent, so it belongs in a review checklist rather than in individual judgement. The
default query people write is the wrong one.
The bucket placement is the part that gets neglected. Your p99 is only as accurate as the
bucket boundaries near your p99, because histogram_quantile interpolates linearly inside
the containing bucket and assumes a uniform distribution there, which is almost never true.
Prometheus's default buckets are tuned for a roughly 100 millisecond service and are actively
misleading for a 3 millisecond one, where everything lands in the first bucket. So: dense
buckets around the SLO threshold, sparse elsewhere.
The design decision I would defend hardest is two alerts rather than one. The correctly aggregated fleet percentile deliberately hides a single bad instance, which is right for an SLO and useless for triage, because "is one instance slow or are all of them" is the first question in a latency incident. Conflating them is what produced the phantom incident in the example: an average-of-percentiles alert that was really an outlier detector with wrong arithmetic.
And a guard that is cheap and rarely present: suppress percentile computation below a minimum sample count. A p99 over three samples is not a p99, and a pod serving three requests a minute after a probe flap will produce arbitrary numbers that then pollute any aggregate that touches them.
Where I would push further than the question asks: coordinated omission is the larger measurement error and almost nobody checks for it. If a load generator waits for a response before sending the next request, then during a stall it sends nothing, so the worst latencies are never recorded at all and the reported p99 is optimistic by orders of magnitude. That is worth raising whenever someone quotes a benchmark percentile.
Follow-up Q&A
"Why can't you average percentiles?" Because a percentile is an order statistic over a distribution, and you cannot recover the union's order statistic from the components' order statistics. It is a category error rather than an approximation error. The concrete version: a thousand requests at 10 milliseconds and ten requests at 5,000 gives per-instance p99s of 10 and 5,000, whose average is 2,505, while the true p99 over all 1,010 requests is 10, since the slow ten are only one percent. Off by 250 times, in the direction that makes you chase a problem that does not exist.
"What do you do instead?" Aggregate the distribution, not the summary. Each instance
exports histogram bucket counts, which are counters and therefore additive, so you sum the
buckets across instances and compute the quantile from the aggregate. In Prometheus that is
histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m]))), and the sum by (le) is the
entire correction. The query people actually write is avg of a p99 gauge, which is silently
wrong.
"How accurate is that?" Only as accurate as your bucket boundaries near the percentile
you care about, because histogram_quantile interpolates linearly within the bucket
containing the target and assumes a uniform distribution inside it, which is almost never
true. Prometheus's defaults are tuned for a roughly 100 millisecond service; for a 3
millisecond service everything lands in the first bucket and the p99 is interpolated across
zero to five milliseconds, which is meaningless. So place buckets densely around the SLO
threshold and sparsely elsewhere.
"What's the cost of histograms?" Cardinality. Classic Prometheus histograms are one time series per bucket per label combination, so a twelve-bucket histogram with three label dimensions of five values each is 1,500 series for one metric. That is why bucket count is a real decision rather than a formality. Native histograms address it with exponential buckets stored as a single series, which gives better accuracy at a fraction of the cardinality.
"What about t-digest and DDSketch?" Mergeable sketches with bounded relative error at the tails, which is where percentiles matter. t-digest has smallest error at the extremes and is what Elasticsearch's percentile aggregation uses; DDSketch guarantees relative error at any quantile and is what Datadog uses. Their advantage over histograms is that they need no per-service bucket tuning, which for a heterogeneous fleet is real operational work eliminated.
"Can you average percentiles over time?" No, same reason. The mean of 24 hourly p99s is not the day's p99. If one hour has a p99 of 900 milliseconds over a thousand requests and the others are 100 over a million each, the slow hour is a tenth of a percent of the day's traffic and barely moves the daily 99th percentile, while the average of the hourly values reports roughly 500.
"What can you aggregate?" Counters, rates and histogram buckets, all by summing. Means, if weighted by count, because a mean is a sum over a count and both are additive. Min and max. What you cannot aggregate is percentiles, medians (which are percentiles), standard deviations without the sums of squares, and unique counts without a sketch. The weighted-mean exception is worth knowing because it is the one people assume extends to percentiles.
"Your p99 alert fired but the fleet was healthy. What happened?" Almost certainly an average of per-instance percentiles, where one pod serving three requests a minute after a readiness probe flap computed a p99 over three long-poll connections and dragged the average up by orders of magnitude. Three fixes: alert on the summed-histogram quantile, suppress percentile computation below a minimum sample count because a p99 over three samples is not a p99, and add a separate per-instance outlier alert, because the correct fleet percentile deliberately hides one bad instance and that is a real signal you still want.
"Anything else people get wrong about percentiles?" Coordinated omission, and it is larger. If a load generator waits for a response before sending the next request, then during a stall it sends nothing, so the worst latencies are never recorded and the reported p99 is optimistic by orders of magnitude. Gil Tene's talk is the canonical treatment. It is worth raising whenever someone quotes a benchmark percentile, because most load tools have the problem by default.
Common misconceptions
"Averaging percentiles is approximately right." It is a category error and the result can be off by orders of magnitude in either direction.
"A p99 is a p99." It depends on the bucket boundaries, the aggregation window, the sample count and whether the measurement suffered coordinated omission.
"The monitoring tool handles it." Most dashboards make the wrong aggregation the default, and it fails silently.
"Means have the same problem." Means aggregate correctly when weighted by count. That is why they are the one exception.
"A correct fleet percentile is enough." It hides a single bad instance by construction, which is right for an SLO and wrong for triage. You need both signals.
Interview delivery note
Lead with a number, because the magnitude of the error is what makes the point: "You can't, and the error isn't small. A thousand requests at ten milliseconds and ten at five seconds gives per-instance p99s of ten and five thousand, so the average is 2,505. The true p99 over all of them is ten, because the slow ten are one percent of the sample. Off by 250 times, and in the direction that makes you chase a problem that doesn't exist."
Give the reason as a category distinction: "A percentile is an order statistic over a distribution, so you'd need the union's ordering to compute it, and per-instance percentiles have thrown exactly that away. It's not an approximation error, it's the wrong operation."
Then the fix and the clause that is the fix: "So aggregate the distribution rather than the
summary. Export histogram buckets, which are counters and therefore additive, sum them across
instances, and compute the quantile from the aggregate. In Prometheus the whole correction is
sum by (le) before histogram_quantile, and the query people actually write is an avg of
a p99 gauge."
Volunteer the accuracy caveat, because it shows you have tuned one: "and your p99 is only as accurate as the bucket boundaries near it, because histogram_quantile interpolates linearly inside the containing bucket. Prometheus's defaults are tuned for a hundred-millisecond service; on a three-millisecond service everything lands in the first bucket and the p99 is interpolated across zero to five, which is meaningless."
Close with the design point, which is where the judgement is: "and I'd run two alerts, not one. The correct fleet percentile hides a single bad instance by construction, which is right for an SLO and useless for triage, because 'is one host slow or all of them' is the first question in a latency incident. Conflating them is exactly what produces phantom alerts."
Further reading
- The Prometheus documentation on
histogram_quantile, thesum by (le)requirement, and native histograms. - Dunning and Ertl, "Computing Extremely Accurate Quantiles Using t-Digests".
- Masson, Rim and Lee, "DDSketch: A Fast and Fully-Mergeable Quantile Sketch with Relative- Error Guarantees" (VLDB 2019).
- Gil Tene, "How NOT to Measure Latency", for coordinated omission and percentile misuse generally.
- Beyer et al., The Site Reliability Workbook, chapter 4, on measuring SLIs.