PromQL Linter and Explainer

Parse a PromQL expression and check the mistakes that still return a number: an anchored label matcher that silently matches nothing, a rate() outside its aggregation, a histogram quantile over raw counters, and a range too short to hold two samples.

Paste below, or drop a file anywhere on this panel

Or drop a file anywhere on this panel. Nothing is uploaded: the analysis runs in this tab.

The answer appears here

Paste on the left and press Parse and check. Nothing leaves this tab.

Wanted a different tool?

  • Prometheus Relabel Config Tester if no series come back at all, because a relabel rule can drop the target before a query ever sees it, and its regex is anchored the same way.

Examples

Real input you can load into the tool above. Each one shows a different thing going wrong, because that is what the tool is for.

The anchored matcher that returns nothing

A regex label matcher written as a substring search. Prometheus anchors it, so it matches the job called exactly "api" and no series come back.

sum by (job) (rate(http_requests_total{job=~"api"}[5m]))

rate() outside the aggregation

Counters summed before the rate is taken. Counter resets are per series, so one process restarting drags the total down and the correction cannot tell that from a real decrease.

rate(sum(http_requests_total)[5m:])

A histogram quantile over raw counters

Bucket series are cumulative since process start, so this is the 99th percentile of every request the process has ever served. It barely moves, which reads as stability.

histogram_quantile(0.99, http_request_duration_seconds_bucket)

A range too short to hold two samples

rate() computes a slope and needs two points. At the default 15 second scrape interval this window holds one, so the graph is empty and the exporter gets blamed.

rate(http_requests_total[15s])

An aggregation that drops le

le is the bucket boundary. Aggregating by job instead of le leaves the quantile function nothing to work with.

histogram_quantile(0.99, sum by (job) (rate(http_request_duration_seconds_bucket[5m])))

Common mistakes

These are the ones that fail silently. The config is accepted, nothing raises an error, and the consequence arrives later.

  1. Writing a label matcher regex as a substring search

    Prometheus anchors matcher regexes as `^(?:...)$`, so `{job=~"api"}` does not match `api-server`. The query returns no series and nothing errors.

    Instead:Use `{job=~".*api.*"}` for a substring, or `{job="api"}` for an exact match, which is faster because it uses the index.

  2. Aggregating counters and then taking the rate

    Counter resets are per series. Summing first means one process restarting drags the total down, and the reset correction cannot tell that from a genuine decrease. The query runs and returns wrong numbers exactly during a deploy.

    Instead:Rate first, aggregate second: `sum(rate(x[5m]))`, never `rate(sum(x)[5m:])`.

  3. Passing raw bucket counters to histogram_quantile

    Bucket series are cumulative since process start, so the quantile describes every request ever served. It barely moves, which during an incident looks like stability.

    Instead:`histogram_quantile(0.99, sum by (le) (rate(x_bucket[5m])))`. The `le` label is the bucket boundary and the function cannot work without it.

  4. Choosing a range window without checking the scrape interval

    rate() needs two samples in the window to compute anything. At a 15s scrape, `[15s]` holds one and returns nothing, and `[30s]` empties whenever a single scrape is missed.

    Instead:At least four times the scrape interval, so `[1m]` at 15s. That survives one missed scrape.

  5. Using a bare comparison on a graph

    `up > 5` filters rather than returning true or false: series that do not satisfy it disappear entirely, which reads as missing data rather than as a low value.

    Instead:Add `bool` to get 1 and 0. Leave it off for alerting rules, where an empty result correctly means no alert.

The queries that return numbers and mean nothing

PromQL fails in two ways. A syntax error tells you immediately. Everything else returns a perfectly plausible number that is wrong, and a graph that looks fine is the hardest kind of bug to notice. This parses the expression and checks the second kind.

Label matcher regexes are anchored, exactly as in relabel_config

`{job=~"api"}` matches the job called `api` and nothing else. It does not match `api-server`. Prometheus wraps every matcher regex as `^(?:...)$`, the same rule that makes relabeling surprising, and the symptom is the same: the query returns no series and everything looks correct. Write `.*api.*` for a substring match. If you meant an exact match, use `=` instead, which is faster because it can use the index directly.

{job=~"api"}        matches only "api"
{job=~".*api.*"}    matches "api-server"
{job="api"}         the same as the first, and faster
{job=~""}           matches series with NO job label at all

rate() goes inside the aggregation, never outside

Counter resets are per series. `sum(rate(x[5m]))` computes each series' rate, correcting its own resets, and then adds them. `rate(sum(x)[5m:])` adds the counters first, so a single process restarting drags the sum down and the reset correction cannot tell that from a real decrease. The second form parses, runs, and returns numbers that are wrong precisely when a deploy happens, which is when you are looking at the graph.

sum(rate(http_requests_total[5m]))      correct
sum by (job) (rate(http_requests_total[5m]))

rate(sum(http_requests_total)[5m:])     wrong, and it runs

A range needs to hold at least two samples, and really four

rate() computes a slope, so it needs two points inside the window or it returns nothing at all. At a 15 second scrape interval, `[15s]` holds one sample and the graph is empty, which sends people to check the exporter. Two or three samples is worse in a way: it computes, and then a single missed scrape empties the window, so the line flickers and reads as an intermittent target. Four times the scrape interval is the usual floor.

histogram_quantile needs a rate, and it needs le

Bucket series are cumulative counters that only increase since process start. `histogram_quantile(0.99, http_request_duration_seconds_bucket)` therefore describes every request the process has ever served: it barely moves, and during an incident it looks reassuringly stable. Wrap the buckets in a rate. The other half is that `le` is the bucket boundary and the function cannot work without it, so an aggregation must be `sum by (le)` and not `sum by (job)`.

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

A comparison filters rather than returning true or false

`up > 5` does not give you ones and zeros. It drops every series that does not satisfy the comparison. For an alert that is exactly right, because an empty result is no alert. On a graph it means a line vanishes rather than going to zero, which reads as missing data. The `bool` modifier gives you 1 and 0 instead.

What this cannot see

It has no metrics and no metadata, so it cannot know whether a name is a counter or a gauge. Where that matters it says so: a rate() over a name that does not end in `_total` is reported as a naming hint rather than as a verdict, because a gauge called `queue_depth_total` would defeat any rule built on the convention. It also does not evaluate anything, so it cannot tell you a query returns no data because the series does not exist. The scrape interval used for the range checks is 15 seconds, which is the Prometheus default; if yours differs, read those two findings against your own number.