Observability Multi-Cloud

PromQL in Anger: Rate, Histograms, and Aggregation Patterns That Actually Work

Most PromQL bugs are not syntax errors. A syntax error fails loudly — the expression browser turns red, you fix it in thirty seconds. The dangerous bug is the one that parses cleanly, returns a plausible number, ships to a dashboard, and quietly lies for six months. avg(histogram_quantile(0.99, ...)) across forty pods is valid PromQL — and wrong in a way that consistently understates your tail latency, with nothing in the engine to tell you. rate(my_gauge[5m]) is valid PromQL — and nonsense, because rate assumes monotonicity and a gauge has none. The engine hands you a float, you graph it, and the graph is fiction.

This is a working engineer’s tour of the parts of PromQL — Prometheus’s query language for time-series data — that bite people who already know the basics: the data model (what a sample is, why metric type is metadata, not enforcement), the instant-vs-range-vector distinction that underpins everything, the rate/irate/increase family and the counter-reset and extrapolation machinery inside it, aggregation with by/without, classic and native histograms (the le buckets, histogram_quantile, the bucket-boundary interpolation error, why you can never average a percentile), recording rules, subqueries, vector matching and label joins (on, ignoring, group_left, label_replace), the time-shifting modifiers offset and @, and a catalogue of the specific mistakes that produce confident, wrong numbers. Every concept comes with a real query; the high-stakes ones come with a promtool test rules snippet that pins the expected value so a future “cleanup” cannot silently break it.

By the end you will stop guessing. You will know, before a query reaches a dashboard or an alert, whether it computes what you think — which metric type each function assumes, which way a type-mismatch error points, why an increase over an hour returns a non-integer, why a panel is blank, why your p99 is pinned to exactly 10 for a week, and why summing counters before rating them turns one pod’s restart into a fleet-wide negative spike. Knowing which within ninety seconds is what separates a five-minute fix from a half-day at the wrong graph.

What problem this solves

PromQL is deceptively forgiving. The engine evaluates almost any well-typed expression and returns a vector, a vector renders on a graph, and a graph looks authoritative. That forgiveness is the entire problem: the language does not enforce the semantic contracts that make a number true. It does not stop you calling rate() on a gauge, averaging quantiles, or summing counters across instances before rating them, and it does not warn you that your range window is narrower than your scrape interval so the function silently returns nothing. Each produces output — just not output that means what you assumed.

What breaks without this knowledge is subtle and expensive. An on-call engineer builds a latency panel that averages per-pod p99s; during a failover two pods get cold caches and their real p99 jumps to 8 seconds, but the averaged panel barely moves past 1.2 seconds and no alert fires while customers time out. A capacity dashboard charts a rate with a 1-minute window against a 60-second scrape, flickers between a value and a gap, and gets deleted because nobody trusts it. An SRE writes a burn-rate alert on a counter, gets the window-to-scrape ratio wrong, and it flaps on every missed scrape. None are syntax errors; all passed review because the query looked right and returned a number.

Who hits this: everyone who graduates from copy-pasting example queries to writing their own — especially anyone building alerting rules (where a wrong number wakes someone or fails to), SLO dashboards (where averaged percentiles are endemic), and multi-instance aggregations (where rate-then-sum order and by/without decide correctness). The fix is internalising a handful of semantic rules, knowing the metric type before you choose a function, and validating the dangerous queries against synthetic data with promtool before they reach production.

To frame the field, here is every class of PromQL mistake this article disarms, its symptom, and the section that fixes it:

Mistake class What you see Why it’s wrong Section that fixes it
Type mismatch “expected type range vector … got instant vector” (or reverse) Passed an instant vector to rate, or graphed a range vector Instant vs range vectors
rate on a gauge A plausible per-second number that’s meaningless rate assumes monotonicity; gauges go down The rate family · Common mistakes
Rate-then-sum reversed Sudden negative or huge spikes when a pod restarts sum before rate destroys per-series reset detection Aggregation · Common mistakes
Averaged percentiles Tail latency that’s always too low, never alerts Quantiles are not linearly combinable Histograms · Common mistakes
Dropped le “histogram_quantile … no le label” or NaN Aggregation collapsed the bucket-boundary label Histograms
Window ≤ scrape interval Flickering or empty rate panels rate needs ≥2 samples in the window Range semantics · Common mistakes
Non-integer increase increase(...[1h]) = 1342.8 for whole requests Extrapolation to window edges The rate family
Many-to-many match error “many-to-many matching not allowed” Duplicate series on the “one” side of a join Joins and vector matching

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You should have Prometheus scraping at least one target, be able to open the expression browser at /graph, recognise the exposition format (http_requests_total{job="api",code="200"} 4823), know that Prometheus pulls on a scrape interval (commonly 15s or 30s), and have written a trivial query like up. Familiarity with HTTP status codes, what a percentile is, and labels as key-value dimensions helps. No prior promtool experience is required — the lab introduces it from zero.

This sits in the Observability — Metrics track and is the language layer everything else consumes: upstream, instrumentation produces the series you query; downstream, queries power dashboards and alerts. It pairs most tightly with Scaling Prometheus: Recording Rules, Remote-Write, and Long-Term Storage with Thanos and Mimir (recording rules, operationalised) and Taming Metric Cardinality: Relabeling, Limits, and Cost Governance in Prometheus (the label sets you query). Your queries become panels in Engineering Grafana Dashboards That Get Used: RED, USE, Template Variables, and Provisioning-as-Code and alerts routed by Designing Alertmanager Routing Trees: Grouping, Inhibition, Silences, and Dedup, and the SLO math here underlies SLOs and Error Budgets in Practice: Defining SLIs and Building Multi-Window Burn-Rate Alerts.

A quick map of where each PromQL concept lives, so you know what owns what when a number looks wrong:

Concept Where it’s produced Where you use it Failure it causes if wrong
Metric type (# TYPE) Instrumentation / exporter Choosing a function Wrong function → meaningless number
Labels / cardinality Relabeling + instrumentation Selectors, aggregation Match failures; cost blowups
Scrape interval scrape_configs Range-window sizing Empty/flickering rate
Buckets (le) Histogram instrumentation histogram_quantile Quantisation error; pinned p99
Recording rules rule_files Dashboards + alerts Stale/duplicated series if untested
Alert thresholds Alerting rules for:, burn-rate Flapping or silent alerts

Core concepts

Five mental models make every later diagnosis obvious. The rest of the article is consequences.

A series is a named, labelled stream of (timestamp, float64) samples — and the name is just a label. A series is uniquely identified by a metric name plus labels, and the name is itself sugar for the reserved __name__ label, so these two are exactly the same selector:

http_requests_total{job="api", code="200"}
{__name__="http_requests_total", job="api", code="200"}

Every series is an append-only stream of (timestamp, float64) samples at roughly the scrape interval. Everything PromQL does is select some of those streams and transform them. There is no other primitive.

The metric type is metadata you must honour in your head — the engine does not enforce it. There are four types — counter, gauge, histogram, summary — and the type lives only in the metric’s # TYPE line and in your understanding. PromQL will happily call rate() on a gauge or avg on a counter and return a number; get the type wrong and you get a value, just not a true one. This single fact is the root of more wrong dashboards than any other.

Type What it stores Monotonic? Correct operations Never do this
Counter Cumulative total, resets to 0 on restart Yes (until reset) rate, irate, increase Read the raw value; avg/max the raw counter
Gauge A value that moves up and down No Read raw; avg/max/min/sum; delta, deriv, predict_linear rate/increase (assumes monotonicity)
Histogram Cumulative _bucket{le} + _sum + _count Buckets are counters rate the buckets, then histogram_quantile; ratio buckets avg the per-instance quantile; drop le
Summary _sum + _count + client-side {quantile} sum/count are counters rate on _sum/_count; read quantiles raw (per series) Aggregate the {quantile} series across instances

Counter metrics conventionally end in _total, but that suffix is a hint to humans and changes nothing. The behaviour you rely on — monotonicity plus reset detection — is asserted by the exporter, not the name.

Instant vector vs range vector is the type system you must feel in your fingers. An instant vector is one sample per series at the evaluation timestamp (http_requests_total); a range vector is a set of samples per series over a lookback window written with a duration in brackets (http_requests_total[5m]). Functions like rate, increase, and the _over_time family consume a range vector and return an instant vector. You cannot graph a range vector or pass an instant vector to rate() — the single most common error after the basics, and the error message tells you which way you got it backwards.

Counters are read through rate, never raw. A raw counter value (4823) is meaningless — it only grows and resets. Critically, rate and increase detect counter resets: if the value drops between two samples, the engine assumes a restart and treats the drop as a reset (counting the post-reset rise as continued increase) rather than computing a negative rate. That is why you must never compute rates by hand with subtraction, and why the order of rate and sum matters so much later.

Histograms are a family of series, and percentiles are not averageable. A classic histogram is cumulative _bucket counters (each labelled with an upper bound le) plus _sum and _count; for a percentile you rate() the buckets, aggregate while preserving le, then call histogram_quantile once. Two hard truths the field gets wrong constantly: accuracy is bounded by your bucket layout (interpolation is within the matched bucket), and you cannot average percentiles — it’s meaningless and understates the tail.

The vocabulary in one table

Before the deep sections, pin down every moving part — the mental model side by side (the glossary repeats these for lookup):

Term One-line definition Where it lives Why it matters
Sample One (timestamp, float64) data point Inside a series The only primitive PromQL operates on
Series Name + labels → a stream of samples TSDB Uniquely keyed by __name__ + labels
Instant vector One sample per series at eval time Query result Graphable; consumed by binary ops
Range vector Many samples per series over [w] Intermediate Only input to rate/_over_time
Scalar A single number, no labels 5, time() Right operand of arithmetic
Counter Monotonic cumulative total Metric type Read via rate/increase only
Gauge Up-and-down value Metric type Read raw; never rate
le Bucket upper bound label Histogram buckets Must survive every aggregation
rate() Per-second avg over a window Function Default for counters; reset-aware
histogram_quantile Interpolated percentile from buckets Function Call once, on aggregated buckets
Recording rule Pre-computed series on a schedule rule_files Speeds + stabilises hot queries
Subquery Range query over an instant expression [w:res] Powerful but expensive
Matcher Label filter in {…} Selector =, !=, =~, !~

The selector and matcher reference

Before any function, you select series. A selector is an optional metric name plus zero or more label matchers in braces. The four matcher operators are the foundation of every query — get a =~ anchor wrong and you silently match nothing or everything.

Matcher Meaning Example Gotcha
= Label exactly equals value {job="api"} Empty {job=""} also matches series without the label
!= Label does not equal value {code!="200"} Also matches series missing the label (it ≠ “200”)
=~ Label matches RE2 regex {code=~"5.."} Fully anchored5.. means ^5..$, not “contains 5”
!~ Label does not match regex {code!~"2.."} Anchored too; excludes only full matches

Three rules that cause real bugs: regexes are fully anchored (=~"api" will not match api-prod — use .*api.*); =~"" matches the empty value and series missing the label; and at least one matcher must restrict, so bare {} is a parse error while {__name__=~"node_cpu_.*"} regexes over metric names.

A few selectors that show the range of what is possible:

http_requests_total{job="api", code=~"5.."}      # all 5xx for the api job
{__name__=~"node_.+", instance="10.0.1.4:9100"}  # every node_* metric (name regex)
http_requests_total{canary=""}                   # series with NO 'canary' label
http_requests_total{method=~"GET|POST"}          # anchored: exactly GET or POST, not GETX

The classic empty-result mystery is the anchoring rule: someone writes {handler=~"/api/users"} expecting /api/users/42, gets nothing, and concludes “the metric isn’t there.” It is — the regex is anchored. Always reach for .* when you mean “contains.”

Instant vectors versus range vectors

This is the distinction that unlocks PromQL, and the one that produces the most beginner-to-intermediate errors. Restating it precisely:

http_requests_total              # instant vector: one value per series, at eval time
http_requests_total[5m]          # range vector: many values per series, last 5 minutes

You can graph an instant vector. You cannot graph a range vector — it has no single value to plot per step. The only thing you can do with a range vector is feed it to a function that reduces it back to an instant vector. Those functions are a closed, learnable set:

Range-vector function Returns Use for Metric type
rate(v[w]) Per-second average Smoothed counter rate Counter
irate(v[w]) Instantaneous rate (last 2 samples) Twitchy live rate Counter
increase(v[w]) Total increase over window “How many in the last hour” Counter
delta(v[w]) First-to-last difference Gauge change over window Gauge
idelta(v[w]) Last two-sample difference Gauge instantaneous delta Gauge
deriv(v[w]) Per-second derivative (least-squares) Gauge slope Gauge
predict_linear(v[w], t) Extrapolated value t seconds ahead Capacity / disk-fill Gauge
avg_over_time(v[w]) Mean of samples in window Smooth a noisy gauge Gauge
max_over_time / min_over_time Extremes in window Peak/trough gauge Gauge
sum_over_time(v[w]) Sum of samples (not a rate!) Rarely; integral-like Gauge
count_over_time(v[w]) Number of samples present Scrape-presence checks Any
last_over_time(v[w]) Most recent sample in window Staleness-tolerant read Any
present_over_time(v[w]) 1 if any sample exists “Did this series exist” Any
quantile_over_time(φ, v[w]) φ-quantile of a gauge’s samples Distribution of a gauge over time Gauge

The type-mismatch error is your friend because it names the direction of your mistake. Memorise both:

What you wrote Error message What it means Fix
rate(http_requests_total) “expected type range vector in call to function rate, got instant vector” You forgot the [w] Add a window: rate(...[5m])
http_requests_total[5m] on a graph “invalid expression type range vector for range query, must be Scalar or instant vector” You tried to graph a range vector Wrap it: rate(...[5m]) or avg_over_time(...[5m])
rate(rate(x[5m])[5m]) “expected type range vector … got instant vector” Inner rate already returned instant Use a subquery: rate(...[5m])[10m:1m]

How the window interacts with the graph step

When you render a range query over time, Prometheus evaluates your instant-vector expression at each step across the dashboard’s time range; the [5m] is the lookback applied at each of those steps independently. Two rules keep this honest:

Relationship Symptom if violated Rule of thumb
Window vs step Gappy line — adjacent points share no data Window ≥ step (Grafana’s $__rate_interval does this for you)
Window vs scrape interval Flickering or empty rate (needs ≥2 samples) Window ≥ ~4× scrape interval
Window too large Over-smoothed; spikes flattened Don’t exceed ~10× scrape for alerting rates

With a 30-second scrape, [2m] gives roughly four samples per window — enough to survive one missed scrape and still compute a rate. A window exactly equal to the scrape interval frequently catches only one sample (depending on alignment), so rate() flickers between a value and nothing — always give the window slack. The most common “my panel is blank” cause is a window narrower than the scrape interval, or a series too new to have two samples in the window.

The rate family in depth

Counters only go up until a restart sets them back to 0. Three functions turn that sawtooth into something meaningful, and choosing the wrong one — or the wrong window — is a daily source of misleading graphs.

# Per-second average request rate over 5 minutes (the default for graphs and alerts)
rate(http_requests_total{job="api"}[5m])

# Instantaneous rate from the last two samples (live, twitchy)
irate(http_requests_total{job="api"}[1m])

# Absolute count of requests in the last hour (NOT per-second)
increase(http_requests_total{job="api"}[1h])

The three functions compared, with the trade-off that decides which you pick:

Function What it computes Window samples used Reacts to spikes Use for Avoid for
rate(v[w]) Per-second avg increase across the window All in [w] Smoothly Alerting, graphing, SLOs Sub-second spike detection
irate(v[w]) Per-second rate from last two samples Last 2 Instantly (jumpy) Live high-res dashboards Alerting (too noisy)
increase(v[w]) Total increase over [w] (≈ rate × w) All in [w] Smoothly “How many in period X” Per-second comparisons

Counter-reset detection — what actually happens

When rate/increase find a sample lower than its predecessor, they treat it as a reset: the drop is read as the counter resetting to 0 and the post-reset rise counts as continued increase. So a pod restarting mid-window produces a sensible rate — as long as you rate the per-instance series before aggregating. sum first, and the per-series reset information is gone: a restart looks like the aggregate going backwards.

Scenario What the samples do What rate returns Why
Normal increase 10 → 20 → 30 Steady positive rate Straightforward slope
Single restart mid-window 90 → 100 → 5 → 15 Sensible positive rate Drop treated as reset, not negative
sum() then rate() across restart Aggregate dips when one instance resets Spurious spike/negative Reset detection lost in the sum
Window with one sample [w] catches a single point Empty (no result) Needs ≥2 samples
Brand-new series Just started scraping Empty until 2nd scrape Same reason

Extrapolation — why increase returns non-integers

Both rate and increase extrapolate to the exact edges of the window. Window boundaries rarely align with scrape timestamps, so the engine extends the observed slope to the start and end — which is why increase(http_requests_total[1h]) can return 1342.8 even though you counted whole requests. That is expected, not a bug; treat it as an estimate. Two more behaviours follow from the same machinery: if extrapolation near the start of a series would project below zero, Prometheus clamps it to the series start so it never invents negative history; and stale markers end a series cleanly, so gaps don’t produce phantom rates.

Real queries you’ll write with this family:

# 5xx error rate per second, smoothed — the canonical alerting input
sum by (job) (rate(http_requests_total{code=~"5.."}[5m]))

# Requests per second by route, dropping the noisy instance label
sum without (instance) (rate(http_requests_total[5m]))

Aggregation operators and by / without

Aggregation operators collapse many series into fewer. The grouping clause decides which labels survive — and that decision is your dashboard’s grouping and your alert’s identity.

# Total request rate per service — keep only `job`, collapse everything else
sum by (job) (rate(http_requests_total[5m]))

# Same intent, but keep every label except the noisy per-pod `instance`
sum without (instance) (rate(http_requests_total[5m]))

The full set of aggregation operators, what they do, and the parameter (if any) they take:

Operator Collapses series by Parameter Typical use
sum Adds values Total rate/count across instances
avg Mean Average gauge (CPU%, memory%)
min / max Extremes Worst/best instance
count Number of series in group How many instances/series
count_values Buckets by value, counts each "label" Distribution of a discrete value
group Returns 1 per group (existence) Set membership / joins
stddev / stdvar Spread Variance across instances
topk / bottomk Keeps the N highest/lowest series k “Noisiest N” panels
quantile φ-quantile across series φ (0–1) Cross-instance distribution (NOT histogram p99)

by versus without — the choice has a real robustness consequence as labels evolve:

Clause Keeps Behaviour when a new label appears upstream Best for
by (a, b) Only a, b Silently dropped — panels unchanged Tightly controlled top-line numbers
without (x) Everything except x Automatically carried through — panels gain detail Dashboards meant to surface new dimensions
(neither) Nothing (single series) n/a Grand totals only

without is often the more robust default: add a region label upstream and by (job) silently drops it (panels unchanged) while without (instance) carries it through (panels gain detail). Prefer without for exploratory dashboards; by for a single authoritative number where extra labels would split the series unexpectedly.

The order rule: rate before sum, always

The rule the counter-reset section set up: always rate() the per-instance counters first, then sum:

# CORRECT — reset detection happens per instance, then we add the rates
sum(rate(http_requests_total[5m]))

# WRONG — summing counters across instances destroys per-instance reset
# detection; one pod restarting looks like the aggregate going backwards
rate(sum(http_requests_total)[5m])     # also a type error without a subquery
Form Reset detection Correct? Note
sum(rate(x[5m])) Per series, before sum Yes The canonical pattern
rate(sum(x)[5m]) None (sum already merged) No And needs a subquery to even parse
avg(rate(x[5m])) Per series Yes (if you want a mean rate) Different meaning from sum
sum(increase(x[1h])) Per series Yes Total count across instances

topk, bottomk, and per-timestamp evaluation

topk(k, ...) and bottomk(k, ...) are evaluated independently at every timestamp, so the membership of the top 5 can change across a graph — a pod in the top 5 at 10:00 may drop out at 10:05, producing lines that start and stop. Correct, but surprising.

# Top 5 CPU-hungry pods, recomputed every step
topk(5, sum by (pod) (rate(container_cpu_usage_seconds_total[5m])))

# Distribution of HTTP status codes by count of series (creates a new label — watch cardinality)
count_values("code", http_requests_total)

A frequent confusion: quantile(0.99, http_request_duration_seconds) is an aggregation operator computing the 99th percentile of the per-series values at this instantnot your request latency p99, which comes from histogram_quantile over buckets (next section). Mixing them up produces a number that looks like a latency percentile and is not.

Histograms done right

A classic Prometheus histogram is not one series but a family: cumulative _bucket counters (each labelled with an upper bound le), plus _sum and _count. “Cumulative” is the key word: the le="0.5" bucket counts every observation ≤ 0.5, le="1" counts everything ≤ 1 (including the ≤ 0.5 ones), and so on up to the mandatory le="+Inf" bucket that counts everything.

Series in the family What it holds Type You use it for
<name>_bucket{le="X"} Cumulative count of observations ≤ X Counter histogram_quantile, fraction-under-N
<name>_bucket{le="+Inf"} Total count (all observations) Counter Equals _count; denominator
<name>_sum Sum of all observed values Counter Average = _sum/_count
<name>_count Number of observations Counter Denominator; total rate

Computing a percentile correctly

To get a percentile you rate() the buckets first, then apply histogram_quantile, preserving le through the aggregation:

histogram_quantile(
  0.95,
  sum by (le) (rate(http_request_duration_seconds_bucket[5m]))
)

The order is fixed and falls out of the types: (1) rate(..._bucket[5m]) — buckets are counters, so you need per-second rates; (2) sum by (le) — combine instances while preserving le (drop it and the function has nothing to interpolate over); (3) histogram_quantile(0.95, …) — compute the percentile once, on the whole fleet.

The bucket-boundary interpolation error

histogram_quantile does linear interpolation within the matched bucket. If your p99 lands in a bucket spanning [1s, 10s], the answer is a guess in that range — and if the true p99 is 9.9s, the interpolation is wildly off. Two failure modes follow:

Symptom Cause Confirm Fix
p99 pinned to a round number for days The quantile falls in the last finite bucket; +Inf has no upper bound to interpolate to, so the function returns the last finite le Look at the bucket le values; the result equals one of them Add higher finite buckets above your real tail
p99 jumps in coarse steps Buckets too wide around the percentile of interest Interpolation range is large Add buckets densely around your SLO threshold
histogram_quantile returns NaN The le label was dropped, or no observations sum by (le) missing, or zero traffic Restore by (le); check there’s traffic

The design rule: lay out buckets around your SLO thresholds, not on a generic exponential scale. For “p99 under 300ms,” use buckets like 0.1, 0.2, 0.25, 0.3, 0.35, 0.5, 1 — dense around 0.3 — not the default 0.005, … , 10. Quantisation error is bounded by the width of the bucket your percentile lands in, so make it narrow where it matters.

Why you can never average a percentile

Averaging per-instance p95s across pods is meaningless and consistently understates tail latency: a percentile is a property of a distribution, and the mean of several percentiles is not the percentile of the combined distribution. Always aggregate the buckets and compute the quantile once.

Approach Expression shape Correct? What it actually computes
Average the per-instance quantiles avg(histogram_quantile(0.99, rate(..._bucket[5m]))) No A meaningless mean of distinct distributions’ tails — too low
Aggregate buckets, quantile once histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m]))) Yes The fleet-wide p99
Per-instance quantile (one pod) histogram_quantile(0.99, sum by (le, instance) (rate(..._bucket[5m]))) Yes One instance’s p99 (keep instance)

Fraction-under-threshold without quantiles

For an SLO-style “what fraction of requests were under 300ms” question, skip quantiles and ratio the buckets — exact, no interpolation, as long as a bucket boundary sits at your threshold:

# Fraction of requests faster than 300ms (requires an le="0.3" bucket)
sum(rate(http_request_duration_seconds_bucket{le="0.3"}[5m]))
/
sum(rate(http_request_duration_seconds_count[5m]))

This is the basis of SLO compliance math: numerator is “good” requests (under the objective), denominator is all requests. It is exact, cheap, and sidesteps the bucket-boundary error — which is why latency SLOs should be defined on a bucket boundary you control.

Native (exponential) histograms

Recent Prometheus supports native histograms (also called exponential or sparse histograms), which store buckets far more efficiently with automatic exponential boundaries, sidestepping manual le layout. The mental model is the same — histogram_quantile works on them too — but the wire format, storage, and a few functions differ.

Aspect Classic histogram Native histogram
Buckets Fixed le boundaries you choose Automatic exponential, high resolution
Series count One per bucket (cardinality cost) A single series carrying the whole histogram
le layout pain Real — must match SLOs Largely gone
Quantile accuracy Bounded by your bucket widths Bounded by the (fine) exponential schema
histogram_quantile sum by (le) (rate(..._bucket[w])) histogram_quantile(φ, rate(<native>[w])) — no le
Fraction-under-N Ratio le buckets histogram_fraction(0, 0.3, rate(<native>[w]))
Maturity / flags Stable, default Newer; may require enabling per version
# Native histogram: no le, no sum by (le) — rate the single series and ask for the quantile
histogram_quantile(0.95, rate(http_request_duration_seconds[5m]))

# Fraction of requests under 300ms on a native histogram
histogram_fraction(0, 0.3, rate(http_request_duration_seconds[5m]))

If you control instrumentation on a recent version, evaluate native histograms — the le-bucket pain and the cardinality cost of many _bucket series largely go away. Until then, classic histograms with carefully placed buckets are the workhorse, and everything here about preserving le and never averaging percentiles applies.

Vector matching and label joins

Binary operators between two instant vectors match series by their full label set by default. When the label sets differ, tell PromQL how to match with on (only these labels) or ignoring (everything except these), and for unequal cardinality add group_left/group_right.

A canonical ratio — error rate as a fraction of total — where both sides already share labels:

# Both sides carry identical `job` labels, so default matching works
sum by (job) (rate(http_requests_total{code=~"5.."}[5m]))
/
sum by (job) (rate(http_requests_total[5m]))

The matching keywords and what each one does:

Keyword Meaning Example
(default) Match on the full label set a / b (identical labels)
on (labels) Match using only these labels a / on (job) b
ignoring (labels) Match on all labels except these a / ignoring (instance) b
group_left(extra) Left side is “many”; carry extra labels from the left, optionally copy extra from the right many-to-one ratio / enrichment
group_right(extra) Right side is “many” mirror of group_left

Many-to-one matching

The interesting case is many-to-one, where one side has extra labels — say per-code error fractions against a per-job total (left has job and code, right only job):

sum by (job, code) (rate(http_requests_total[5m]))
/ on (job) group_left
sum by (job) (rate(http_requests_total[5m]))

on (job) matches by job; group_left declares the left side as the “many” (it may have extra labels — here code), and the result keeps the left side’s full labelling. Use group_right for the mirror image. The decision table:

You want Side with extra labels Keyword Result labels
Per-code fraction of a per-job total Left / on (job) group_left Left’s (job, code)
Per-job value scaled by a per-code factor Right * on (job) group_right Right’s labels
One-to-one ratio, differing noise labels Neither (equal) / ignoring (instance) Shared labels

Enrichment joins with an info metric

group_left also does enrichment — pulling labels from a metadata “info” metric (like kube_pod_info, which is always 1) onto your real metric. The arithmetic is just a vehicle for the label copy:

# Attach the `node` label from kube_pod_info onto each pod's CPU rate
sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
* on (pod) group_left(node)
kube_pod_info

Here group_left(node) copies the node label off kube_pod_info onto each CPU series; multiplying by kube_pod_info (value 1) leaves the numbers unchanged — the point is the label join. This pattern — multiply by an info metric to attach its labels — is how you slice app metrics by infrastructure dimensions you did not export directly.

Join purpose Pattern Why it works
Add infra label to app metric app_metric * on (pod) group_left(node) kube_pod_info kube_pod_info is 1; copies node
Filter app metric to a label set app_metric and on (pod) kube_pod_info{namespace="prod"} and keeps left series with a match
Exclude a set app_metric unless on (pod) kube_pod_info{phase="Terminating"} unless drops matches

Set operators

Beyond arithmetic, three logical operators combine vectors by label-set membership rather than by value: and keeps left series that have a match on the right (“only where both exist”), or is a union (left series plus right series with no left match), and unless keeps left series with no match on the right (exclusion).

# Request rate only for pods that are currently Running
sum by (pod) (rate(http_requests_total[5m]))
and on (pod)
kube_pod_status_phase{phase="Running"} == 1

If a binary operation returns “many-to-many matching not allowed,” you have duplicate series on the side you think is unique. Either the join key isn’t unique (add the missing label to on/ignoring) or aggregate that side to one series per match group first. This is almost always a label-cardinality surprise, not a bug in PromQL.

Synthesising and rewriting labels: label_replace and label_join

label_replace adds or rewrites a label using a regex capture; label_join concatenates several labels into one. Both are essential for normalising labels before a join or for shaping output for a dashboard.

# Create a `device_simple` label by stripping the partition number from `device`
label_replace(
  node_filesystem_avail_bytes,
  "device_simple", "$1", "device", "(/dev/[a-z]+)[0-9]*"
)

# Build a `target` label by joining job and instance with a colon
label_join(up, "target", ":", "job", "instance")
Function Signature (positional) Does
label_replace(v, dst, repl, src, regex) dst label = repl with $1… from regex applied to src Add/rewrite one label via regex
label_join(v, dst, sep, src1, src2, …) dst = src1 + sep + src2 + … Concatenate labels

A common real use: the two sides of a join carry the same value under different label names (pod vs pod_name). label_replace renames one so the on clause lines up — without it, the join silently matches nothing.

Subqueries, offset, and the @ modifier

offset — shifting the lookback in time

offset shifts a vector’s evaluation back by a fixed duration (or forward, with a negative offset) — perfect for week-over-week comparisons:

# This week's rate versus the same 5-minute window 7 days ago
sum(rate(http_requests_total[5m]))
/
sum(rate(http_requests_total[5m] offset 7d))

@ — pinning to an absolute timestamp

The @ modifier pins a vector’s evaluation to a fixed Unix timestamp (or start()/end() of the query range), regardless of the step rendered. This is how you draw a flat reference line — “the value at deploy time” — across a whole graph:

# Ratio of current rate to the rate at a fixed instant (a deploy timestamp)
sum(rate(http_requests_total[5m]))
/
sum(rate(http_requests_total[5m] @ 1700000000))
Modifier Effect Typical use Note
offset 7d Look back 7 days from each step Week-over-week comparison Combine with rate: rate(x[5m] offset 7d)
offset -1h Look forward 1 hour (negative) Rare; aligning future-shifted data Requires negative-offset support
@ 1700000000 Pin to an absolute Unix time Flat reference line at an event Value is constant across the graph
@ start() / @ end() Pin to the query range’s start/end Baseline at range edges Resolves per query, not per step

Subqueries — a range query over an instant expression

A subquery lets you run a range function over the result of an instant expression, with the syntax [outer_range:resolution]. This is how you ask “what was the max 5-minute rate over the last hour”:

# Highest 5-minute request rate seen in the last hour, sampled every minute
max_over_time(
  sum(rate(http_requests_total[5m]))[1h:1m]
)

The [1h:1m] evaluates the inner expression every minute across a 1-hour window, then max_over_time reduces those 60 points. Subqueries are powerful but expensive — they materialise many intermediate points at query time on every dashboard refresh. The cost characteristics:

Subquery aspect Behaviour Cost implication
Inner resolution (:1m) Inner expr evaluated at that step Finer = more points = slower
Outer range ([1h…]) How far back to evaluate Wider = more points
Evaluation time At query time, every refresh Hot panels recompute constantly
Nesting rate(metric[5m])[30m:1m] legal Compounds cost quickly
Alternative A recording rule for the inner expr Pre-computed; subquery becomes cheap range read

The rule of thumb: a subquery is fine for ad-hoc investigation, but anything you run repeatedly (a panel, an alert) should have its inner expression promoted to a recording rule so the engine isn’t recomputing it on every evaluation.

Recording rules

A recording rule pre-computes an expression on the server at a fixed interval and stores the result as a new series — turning an expensive, repeated query into a cheap read, and giving dashboards and alerts a single materialised series to share so they can never drift apart.

# rules/recording.yml
groups:
  - name: http_rates
    interval: 30s
    rules:
      - record: job:http_requests:rate5m
        expr: sum by (job) (rate(http_requests_total[5m]))
      - record: job:http_request_duration_seconds:p99_5m
        expr: |
          histogram_quantile(
            0.99,
            sum by (job, le) (rate(http_request_duration_seconds_bucket[5m]))
          )

Follow the level:metric:operations naming convention to encode meaning and prevent collisions: the prefix is the aggregation level / grouping labels (job:, instance:, cluster:), the middle is the underlying metric (http_requests), and the suffix is what was done (:rate5m, :p99_5m, :sum) — as in the job:http_requests:rate5m rule above.

Why and when to record, versus leaving a query inline:

Reason to record Benefit Watch-out
Expensive query on a hot dashboard Panel loads instantly Adds a new series (storage)
Same expression used by alert + dashboard They share one materialised truth Keep the rule’s expr authoritative
Quantile over high-cardinality buckets Quantile computed once per interval Choose grouping labels carefully
Subquery you run repeatedly Inner expr precomputed Don’t over-record everything
Cross-rule dependency Build layered aggregations Order within a group matters (sequential)

Three operational rules that bite if ignored:

Rule Why Consequence if violated
Rules in a group run sequentially A later rule can use an earlier rule’s output Reorder if you depend on a freshly recorded series
interval should align with use Too coarse → stale; too fine → load Burn-rate alerts need a fitting interval
The recorded series inherits the expr’s labels Grouping in the expr defines the new series Forgetting le in a recorded quantile = NaN

The single biggest correctness win: an alert and its panel both read job:http_request_duration_seconds:p99_5m instead of each re-deriving the quantile, so a “cleanup” that reintroduces an averaged percentile on the dashboard can’t make the two disagree — they’re the same series.

Architecture at a glance

PromQL has no diagram here — its “architecture” is a small, strict evaluation pipeline you can hold entirely in your head, and seeing it as a pipeline is what makes wrong queries obvious. Trace a single query, histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))), from the inside out, and you have the whole model.

At the base sits the TSDB: append-only series, each a name plus labels, each holding (timestamp, float64) samples. A selector picks series by name and matchers — here, every http_request_duration_seconds_bucket across all instances and le boundaries. Attaching [5m] promotes that instant vector (one sample per series) to a range vector (five minutes of samples per series), the only thing rate consumes: rate(..._bucket[5m]) applies reset detection and edge extrapolation and emits a per-second rate, collapsing back to an instant vector per (instance, le). Then sum by (le) is an aggregation step — collapsing every label except le into one fleet-wide rate per boundary, with le preserved so the bucket structure survives. Finally, histogram_quantile(0.99, ...) reads those cumulative bucket rates and linearly interpolates within the matched bucket, emitting an instant vector with no le — a percentile, ready to graph or threshold.

Read it as a strict layering — TSDB → selector → range vector → rate → aggregation (sum by le) → histogram_quantile → instant-vector result — and every error in this article maps to a layer: a type mismatch crosses the range/instant boundary wrong; a dropped le is an aggregation that destroyed structure the next layer needed; a rate-after-sum is two layers in the wrong order. When a number looks wrong, walk the pipeline from the TSDB outward and ask at each layer, “what type do I have, and is it what the next layer needs?” That walk is the debugging method.

Real-world scenario

Meridian Pay runs a checkout API on Kubernetes — roughly 40 pods behind a service, scraped every 30 seconds by a single Prometheus, with a hard SLO of p99 checkout latency under 1 second. The latency panel and the paging alert were both inherited from a previous team and had “worked” for a year.

The panel used avg(histogram_quantile(0.99, rate(checkout_duration_seconds_bucket[5m]))) — a quantile computed per pod, then averaged across the forty pods — and the alert read the same expression. On an ordinary Tuesday a cache tier degraded and two pods that lost their warm cache saw real p99 climb to 8 seconds while the other thirty-eight stayed near 200ms. Customers on those two pods timed out at the payment step. The dashboard crept from 210ms to about 540ms; the alert, thresholded at 1s on that same averaged number, never fired. The SLO board stayed green through a customer-facing incident, and the on-call engineer only found out from a support escalation forty minutes in.

The breakthrough was walking the query pipeline. The engineer pulled the per-pod p99 with histogram_quantile(0.99, sum by (pod, le) (rate(checkout_duration_seconds_bucket[5m]))) and immediately saw two pods pinned at 8s while the rest sat at 200ms. The averaged panel had diluted two catastrophic tails across thirty-eight healthy ones — the lie of averaged percentiles. Worse, the buckets were a generic exponential layout (…, 1, 2.5, 5, 10) with nothing between 0.5 and 1, so even the correct fleet p99 had a coarse interpolation step right at the SLO boundary — it could swing 200ms from interpolation noise alone.

The fix landed in two parts. First, the query: replace the averaged-quantile panel and alert with the correct aggregate, computed once over the fleet’s buckets:

# Correct fleet-wide p99 — aggregate the buckets (keep le), quantile once
histogram_quantile(
  0.99,
  sum by (le) (rate(checkout_duration_seconds_bucket[5m]))
)

That was promoted to a recording rule, job:checkout_duration_seconds:p99_5m, so the alert and dashboard read the same materialised series and could never drift apart again. Second, the buckets: instrumentation placed explicit boundaries around the 1s SLO — …, 0.5, 0.75, 0.9, 1, 1.5, 2, … — so interpolation near the threshold became tight. They also added a per-pod outlier panel (max by (pod) (histogram_quantile(0.99, sum by (pod, le) (rate(checkout_duration_seconds_bucket[5m]))))) to catch the “two bad pods” case a fleet aggregate can still mask if the fleet is large enough.

Finally, they locked it with a promtool test: a synthetic two-pod skew with an expected fleet p99 that the averaged form gets wrong, so a future “simplification” back to avg(...) fails CI. The next degradation — three weeks later, same cache tier — paged within the for: 5m window with p99 jumping cleanly to 8s on both alert and panel. Same data, same Prometheus, correct math. The lesson on the wall: “You cannot average a percentile, and a generic bucket layout hides the truth at exactly the threshold you care about.”

The incident as a timeline, because the order of moves is the lesson:

Time Symptom What they looked at What it showed Right move
T+0 Customers timing out at checkout (alert silent) Dashboard at 540ms, “green” Don’t trust an averaged-quantile panel
T+15m Support escalation The averaged p99 panel Still under 1s Pull per-pod p99 instead
T+25m Two pods suspected sum by (pod, le) quantile Two pods pinned at 8s The averaged form diluted the tail
T+40m Root cause clear Bucket le values Nothing between 0.5 and 1 Buckets must straddle the SLO
T+1h Mitigated Correct fleet p99 + per-pod outlier Both showed the spike Make it a recording rule
+3 weeks Recurrence Same cache tier degrades Paged in 5m, p99 = 8s clean The fix held; CI test prevents regression

Advantages and disadvantages

PromQL’s design — a forgiving, type-light, vector-based language over a labelled TSDB — both makes it extraordinarily expressive and makes the silent-wrong-number class of bug possible. Weigh it honestly:

Advantages (why the model is powerful) Disadvantages (why it bites)
Labels-as-dimensions make slicing, grouping, and joining trivial without a schema migration Cardinality is invisible until a join fails or storage explodes; the language won’t warn you
Range vectors + rate give correct, reset-aware rates from raw counters automatically The engine doesn’t enforce metric type — rate on a gauge or avg on a counter returns a plausible, wrong number
histogram_quantile computes percentiles server-side from cheap bucket counters Accuracy is bounded by bucket layout, and the function silently returns the last finite le when the tail is unbounded
Recording rules let you pre-compute once and share one truth across alerts and dashboards Forget to record a hot subquery and every panel refresh recomputes it, hammering the server
Vector matching (on/group_left) joins app metrics to infra metadata with no ETL A non-unique join key throws “many-to-many” and stops the query — a cardinality surprise mid-incident
offset/@/subqueries express week-over-week and “max over a window” cleanly Subqueries are expensive and easy to nest into accidental load
The expression browser + promtool give a fast validate-before-ship loop Nothing forces you to use it — a wrong-but-valid query ships exactly as easily as a right one

The model is right for metrics-driven observability where you want flexible, label-based querying without predefining a schema and are willing to internalise a handful of semantic rules. It bites hardest on teams that treat PromQL as “SQL-ish” and assume the engine catches their mistakes (it won’t), on latency SLOs built by averaging percentiles, and on high-cardinality joins written without checking uniqueness. Every disadvantage here is manageable — by knowing the metric type, respecting the range-vector boundary, preserving le, recording hot queries, and testing with promtool — which is the entire point of this article.

Hands-on lab

This lab runs entirely locally with a single Prometheus binary and promtool — no cloud account, no cost. You will run the core query families, reproduce the averaged-percentile bug against synthetic series, and pin the correct answer with a rule unit test. It is the fastest way to feel the semantics rather than read them.

Step 1 — Get Prometheus and promtool. Use local binaries (both ship in the same tarball) or a container that mounts the working dir:

prometheus --version && promtool --version          # local binaries
# or: docker run --rm -it -v "$PWD":/work -w /work -p 9090:9090 prom/prometheus:latest --version

Expected: a version banner for both.

Step 2 — Write a minimal config and a rules file. Create prometheus.yml and rules.yml in the working directory:

# prometheus.yml — scrape ourselves so 'up' exists; load the rules
global:
  scrape_interval: 15s
  evaluation_interval: 15s
rule_files:
  - rules.yml
scrape_configs:
  - job_name: prometheus
    static_configs:
      - targets: ["localhost:9090"]
# rules.yml — one recording rule and one alert to test later
groups:
  - name: lab
    interval: 15s
    rules:
      - record: job:http_requests:rate5m
        expr: sum by (job) (rate(http_requests_total[5m]))
      - alert: HighErrorRate
        expr: |
          sum by (job) (rate(http_requests_total{code=~"5.."}[5m]))
            /
          sum by (job) (rate(http_requests_total[5m])) > 0.05
        for: 5m
        labels: { severity: page }
        annotations:
          summary: "5xx ratio above 5% for {{ $labels.job }}"

Step 3 — Validate config and rules before running anything. This is the habit the whole article argues for:

promtool check config prometheus.yml
promtool check rules rules.yml

Expected: SUCCESS per rule. If you mistyped the alert expr, promtool names exactly which rule and why — fix it here, not in production.

Step 4 — Run Prometheus and open the expression browser at http://localhost:9090/graph:

prometheus --config.file=prometheus.yml

Run a few queries to feel the instant-vs-range distinction:

up                          # instant vector — 1 per target, value 1
prometheus_http_requests_total              # raw counter (don't graph this)
rate(prometheus_http_requests_total[5m])    # per-second rate — graph this
prometheus_http_requests_total[2m]          # range vector — Table tab only, can't graph

Expected: up is 1; the raw counter only grows; rate(...) is graphable; graphing the [2m] range vector errors with “invalid expression type range vector for range query” — the type-mismatch lesson, live.

Step 5 — Reproduce the averaged-percentile bug with a unit test. promtool test rules evaluates against synthetic series you declare, so you can construct the two-pod skew deterministically — no live data needed. Create histogram_test.yml:

# histogram_test.yml — prove avg-of-quantiles understates the tail
rule_files: []          # we test ad-hoc expressions, not a rules file here
evaluation_interval: 1m
tests:
  - interval: 1m
    input_series:
      # Pod A: everything fast — all observations land in the le="0.5" bucket
      - series: 'lat_bucket{pod="a", le="0.5"}'
        values: '0+60x10'
      - series: 'lat_bucket{pod="a", le="1"}'
        values: '0+60x10'
      - series: 'lat_bucket{pod="a", le="+Inf"}'
        values: '0+60x10'
      # Pod B: everything slow — observations only land in le="+Inf"
      - series: 'lat_bucket{pod="b", le="0.5"}'
        values: '0+0x10'
      - series: 'lat_bucket{pod="b", le="1"}'
        values: '0+0x10'
      - series: 'lat_bucket{pod="b", le="+Inf"}'
        values: '0+60x10'
    promql_expr_test:
      # CORRECT: aggregate buckets (keep le), quantile once → reflects pod B's slow tail
      - expr: histogram_quantile(0.99, sum by (le) (rate(lat_bucket[5m])))
        eval_time: 10m
        exp_samples:
          - labels: '{}'
            value: 1            # p99 sits in the +Inf-bounded region: returns last finite le boundary (1)
promtool test rules histogram_test.yml

Expected: SUCCESS. The point is constructive: with half the traffic slow (pod B only ever increments +Inf), the correct fleet quantile reflects the slow tail. Average per-pod quantiles instead and pod A’s fast p99 drags the number down — the bug from the scenario. (Prove it by adding a second promql_expr_test with the averaged expression and watching the value come out lower.)

Step 6 — Pin the rate math so the number can never silently change. Add a second test that fixes the extrapolation result — feed a counter that increments by 10 every minute and assert the 5-minute rate:

# rate_test.yml — feed 0,10,20,... and pin the per-second rate
tests:
  - interval: 1m
    input_series:
      - series: 'http_requests_total{job="api", code="500"}'
        values: '0+10x10'
    promql_expr_test:
      - expr: rate(http_requests_total{code="500"}[5m])
        eval_time: 10m
        exp_samples:
          - labels: '{job="api", code="500"}'
            value: 0.16666666666666666     # ~10 increments per 60s

Run with promtool test rules rate_test.yml. If 0.1666… surprises you, good — that’s the per-second math (10 per 60s), now locked against regression.

Validation checklist. You ran promtool check before a server existed, met the instant-vs-range boundary error, built a deterministic histogram skew, and pinned the rate math. Each step maps to a core lesson:

Step What you did What it proves
3 promtool check config/rules Validate before ship — the core discipline
4 Graph rate(...), fail to graph [2m] The range-vector boundary is real and enforced
5 Synthetic two-pod histogram skew Correct aggregation reflects the tail; averaging hides it
6 Pin rate to 0.1666… Extrapolation/per-second math is deterministic and testable

Teardown. Stop Prometheus with Ctrl-C (or stop the container) and remove the small YAML files (and data/ if a local binary created one):

rm -f prometheus.yml rules.yml histogram_test.yml rate_test.yml; rm -rf data/

Common mistakes & troubleshooting

This is the part you bookmark. First as a scannable table you can read while a panel is lying to you, then the worst offenders expanded with the exact confirming query.

# Symptom Root cause Confirm Fix
1 Tail latency always looks fine; alert never fires during a real tail event Averaged percentilesavg(histogram_quantile(...)) per instance Pull per-instance: histogram_quantile(0.99, sum by (instance, le) (rate(..._bucket[5m]))) and see outliers the average hid Aggregate buckets, quantile once: histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m])))
2 A rate panel is blank or flickers Window ≤ scrape interval, or series too new (<2 samples) Check scrape_interval; widen [w] to ~4× it; try count_over_time(x[5m]) Use a window ≥ 4× scrape; for graphs use $__rate_interval
3 Sudden negative/huge spike in an aggregate rate when a pod restarts sum() before rate() lost per-series reset detection Compare sum(rate(x[5m])) vs rate(sum(x)[5m]) around the restart Always rate() first, then sum
4 A plausible-but-wrong per-second number on a gauge rate/increase on a gauge curl .../metrics | grep '# TYPE <metric>' — it says gauge Use deriv, delta, or read raw; rate is counters only
5 histogram_quantile returns NaN le label dropped by aggregation, or no traffic Inspect the inner vector: does it still have le? Restore sum by (le); confirm there are observations
6 p99 pinned to a round number (e.g. exactly 10) for days Percentile falls in the last finite bucket; nothing above to interpolate to List buckets: count by (le) (..._bucket); result equals the top finite le Add higher finite buckets above your real tail
7 A regex matcher returns nothing Regexes are fully anchored {x=~"foo"} won’t match foobar Use {x=~".*foo.*"} to substring-match
8 “many-to-many matching not allowed” Duplicate series on the side you think is unique Aggregate that side to one per group; check the on() key Add the missing label to on, or sum by (...) first
9 increase(x[1h]) returns 1342.8 for whole requests Extrapolation to window edges This is expected — it’s an estimate Treat as approximate; don’t floor for accuracy claims
10 A subquery makes a dashboard crawl Subquery recomputed every refresh Look for [w:res] in hot panels Promote the inner expr to a recording rule
11 Two metrics won’t join though values match Same value under different label names (pod vs pod_name) Compare label sets of both vectors label_replace to rename one before the join
12 Alert flaps on every single missed scrape Rate window too tight; no for: buffer Check window vs scrape; check for: Widen window; add/raise for:; consider multi-window
13 quantile(0.99, latency_seconds) is not your p99 Confused quantile (across series) with histogram_quantile (across buckets) quantile has no le; it’s the 99th pct of series values Use histogram_quantile over _bucket for latency
14 Counter looks like it goes backwards on a raw graph You graphed a raw counter across a restart The sawtooth is the restart resetting to 0 Don’t graph raw counters; use rate/increase

The three worst offenders, expanded, because they cause the most damage and the table can’t carry the full reasoning:

Averaged percentiles (#1) — the tail that never fires. The panel and alert compute histogram_quantile per instance and then avg across instances. A percentile is a property of a distribution; averaging several is meaningless and consistently understates the tail — two bad pods in forty dilute to nothing. Confirm by pulling the per-instance quantile and looking for buried outliers: histogram_quantile(0.99, sum by (instance, le) (rate(http_request_duration_seconds_bucket[5m]))). Fix by aggregating the buckets and computing the quantile once — histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))) — promoted to a recording rule the alert and dashboard share.

Rate-after-sum (#3) — the phantom spike on restart. sum() applied before rate() merges counters across instances, so when one resets to 0 the aggregate dips and rate over it sees a drop — the per-instance reset detection that would have handled it is gone. Confirm by graphing sum(rate(x[5m])) against rate(sum(x)[5m]) across a known restart; the second shows the spurious spike. Fix: always rate() first, then sum. There is no correct use of rate(sum(...)) for counters.

Pinned p99 (#6) — the truth above the top bucket. The 99th percentile falls in the last finite bucket below +Inf; because +Inf has no upper bound to interpolate toward, histogram_quantile returns the largest finite le, and your real p99 sits above it, invisible. Confirm with count by (le) (http_request_duration_seconds_bucket); if the pinned value equals the top finite le, that’s it. Fix: re-instrument with higher finite buckets above your real tail so the percentile lands between two finite boundaries.

Best practices

A compact “which function for which question” reference to keep beside you:

You want… Use Not
Per-second request rate rate(counter[5m]) raw counter; irate for alerts
Count of events in a period increase(counter[1h]) rate (that’s per-second)
Latency p99 across the fleet histogram_quantile(0.99, sum by (le) (rate(_bucket[5m]))) avg(histogram_quantile(...))
Fraction of requests under N ratio of le="N" bucket to total a quantile
Average of a gauge across pods avg(gauge) rate(gauge[…])
Slope / trend of a gauge deriv(gauge[1h]) rate (counters only)
Disk-fill prediction predict_linear(avail[6h], 4*3600) rate
Week-over-week comparison ... / (... offset 7d) a subquery
Max rate over the last hour max_over_time(rate(x[5m])[1h:1m]) nesting rate directly

Security notes

PromQL is a read language, but the queries and the endpoints they run on have real security implications:

The security-relevant query controls and what each protects against:

Control Mechanism Protects against
Query timeout --query.timeout Long-running query DoS
Max samples per query --query.max-samples Memory blowup from huge fan-out
Max concurrency --query.max-concurrency Resource exhaustion under many queries
Auth/network in front Reverse proxy / mTLS Unauthorised read of all metrics
Relabel-drop sensitive labels metric_relabel_configs Leaking secrets via labels in dashboards
Per-tenant authz (LTS) Mimir/Thanos tenant headers Cross-tenant data access

Cost & sizing

PromQL is free, but the shape of your queries and the series they create drive real cost in CPU, memory, and storage:

Rough resource intuition for a single Prometheus (these scale with your data, treat as starting points):

Factor Cheap Expensive Lever
Active series 100k–1M 10M+ Relabel-drop labels; native histograms
Recording rules A few dozen targeted Hundreds, untargeted Record only hot aggregates
Range window [5m] rates [1h]+ rates everywhere Smallest window ≥4× scrape
Subqueries on dashboards None / recorded Many [w:res] on hot panels Promote to recording rules
Histogram buckets 8–12, SLO-aligned, or native 30+ generic per series Native histograms; trim buckets
Quantile grouping labels by (job, le) by (job, instance, path, le) Group at the level you actually use

In INR terms, the cost of PromQL is mostly the cost of the Prometheus (and long-term storage) it runs on: a well-tuned VM handling ~1–2M active series might be a few thousand rupees a month, an untuned one drowning in high-cardinality series several times that and still slow. The cheapest optimisation is almost always fewer, lower-cardinality series and recording the hot aggregates — both shaped by how you write PromQL.

Interview & exam questions

1. Instant vector vs range vector, and which functions consume which? An instant vector is one sample per series at the eval timestamp (http_requests_total); a range vector is a set of samples per series over a lookback window (http_requests_total[5m]). rate, irate, increase, and the _over_time family consume a range vector and return an instant vector; graph instant vectors, not range vectors, and passing the wrong one gives the “expected type range vector … got instant vector” error.

2. Why rate() before sum(), and what breaks if reversed? rate detects counter resets per series — a drop is treated as a restart, not a negative rate — but sum first merges counters, so a restart makes the aggregate dip and rate(sum(...)) produces a spurious spike. Always sum(rate(x[5m])); rate(sum(...)) has no correct use for counters.

3. Why can’t you average percentiles, and how do you get a fleet-wide p99? A percentile is a property of a distribution; the mean of several instances’ percentiles isn’t the percentile of the combined distribution and consistently understates the tail. Aggregate the buckets (preserving le) and compute the quantile once: histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))).

4. What does histogram_quantile do, and what bounds its accuracy? It does linear interpolation within the bucket where the quantile falls, using the cumulative _bucket counters, so accuracy is bounded by that bucket’s width. If the quantile lands in the last finite bucket below +Inf it returns that finite le (no upper bound to interpolate toward), so p99 can pin to a round number.

5. Why might a rate() panel be blank or flicker, and how do you fix it? rate needs at least two samples in the window; a window ≤ scrape interval (or a brand-new series) frequently catches zero or one and returns nothing or flickers. Fix by widening the window to roughly 4× the scrape interval, or use Grafana’s $__rate_interval.

6. Explain on, ignoring, and group_left. Binary operators match by the full label set by default; on (labels) restricts matching to those labels, ignoring (labels) matches on all except them. group_left/group_right handle many-to-one matching — declaring which side may have extra labels — and group_left(extra) also copies extra labels from the “one” side (the enrichment pattern with info metrics like kube_pod_info).

7. Is a non-integer increase a bug? No — rate and increase extrapolate to the exact window boundaries, which rarely align with scrape timestamps, so increase(x[1h]) legitimately returns fractional values like 1342.8. It’s an estimate by design; don’t floor it and claim exactness.

8. When should a query become a recording rule? When it’s expensive (quantiles over many buckets, large aggregations, subqueries) and run repeatedly (a panel or alert). Recording computes it once per interval into a series the alert and dashboard share — so they never diverge — and removes per-refresh recomputation, though each rule adds a series and write load.

9. What is a subquery and what’s its cost? A subquery expr[outer_range:resolution] runs a range function over an instant expression’s result — e.g. max_over_time(sum(rate(x[5m]))[1h:1m]). It materialises many intermediate points at query time on every refresh, so it’s expensive; promote the inner expression to a recording rule for anything run repeatedly.

10. How do offset and @ differ? offset 7d shifts the lookback by a relative duration from each step (week-over-week). @ <unix_ts> pins evaluation to an absolute timestamp regardless of the step (a flat reference line at an event like a deploy), and @ start()/@ end() pin to the query range’s edges.

11. rate() gives a plausible number but the graph behaves oddly — how do you check it’s even a counter? Inspect the type, which lives only in metadata: curl .../api/v1/metadata | jq '.data["<metric>"]' or curl .../metrics | grep '# TYPE <metric>'. If it says gauge, rate is wrong — use deriv/delta or read it raw; the engine never enforces this.

12. quantile(0.99, ...) vs histogram_quantile(0.99, ...)? quantile(0.99, latency_seconds) is an aggregation operator computing the 99th percentile across series values at this instant — not a latency percentile. histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m]))) computes the 99th percentile of the observed distribution from buckets; for request latency p99 you want the latter.

These map to the Prometheus Certified Associate (PCA)PromQL, the data model, recording and alerting rules, instrumentation — and to SRE/observability interview loops generally. The histogram and SLO math underpins burn-rate alerting questions. A compact mapping for revision:

Question theme Where it’s tested Core skill
Instant vs range vectors, type errors PCA · SRE interviews Data model fluency
rate/irate/increase, reset/extrapolation PCA Counter semantics
histogram_quantile, bucket error, averaging trap PCA · SRE Latency/SLO correctness
Aggregation, by/without, rate-then-sum PCA Correct multi-instance math
Vector matching, group_left, joins PCA Cross-metric composition
Recording rules, subqueries, cost PCA · platform interviews Operability

Quick check

  1. You write rate(node_memory_MemAvailable_bytes[5m]) and get a number on the graph. Why is this query meaningless, and what should you use instead?
  2. A latency panel reads avg(histogram_quantile(0.99, rate(http_request_duration_seconds_bucket[5m]))). What is wrong with it, and write the correct fleet-wide p99.
  3. Your rate(http_requests_total[15s]) panel is blank even though traffic is flowing and the scrape interval is 15s. What’s happening and what’s the one-line fix?
  4. An aggregate rate shows a sharp negative spike exactly when one pod restarted. What’s the query mistake and the correct form?
  5. {handler=~"/api/users"} returns nothing, but you can see /api/users/42 requests in another tool. Why, and how do you fix the matcher?

Answers

  1. node_memory_MemAvailable_bytes is a gauge (it moves up and down), and rate assumes a monotonic counter, so it computes a per-second “rate” of a non-monotonic value — nonsense. To see how available memory is changing, read it raw, or use deriv(node_memory_MemAvailable_bytes[1h]) for a slope, or delta(...[1h]) for the change over a window.
  2. It averages percentiles — computing p99 per instance and then averaging, which is mathematically meaningless and understates the tail (a few slow pods get diluted). The correct form aggregates the buckets and computes the quantile once: histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m]))).
  3. The range window ([15s]) equals the scrape interval, so it frequently catches only one sample, and rate needs at least two — hence empty/flickering. Widen the window to at least ~4× the scrape interval: rate(http_requests_total[1m]) (or use $__rate_interval in Grafana).
  4. The query summed counters before rating them (rate(sum(...)) or sum then rate), which destroyed per-instance counter-reset detection, so the restarting pod’s reset looked like the aggregate going backwards. The correct form rates first: sum(rate(http_requests_total[5m])).
  5. PromQL regex matchers are fully anchored, so =~"/api/users" matches only the exact string, not /api/users/42. Use a substring pattern: {handler=~"/api/users.*"} (or ".*\/api\/users.*" if it can appear mid-string).

Glossary

Next steps

You can now read any PromQL query as a pipeline and tell whether it computes what you think. Build outward:

PrometheusPromQLMetricsHistogramsRecording RulesAlertingObservabilitypromtool
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading