Cheat Sheets

PromQL Cheat Sheet: rate(), histogram_quantile, Alert Rules (PDF)

I wanted a PromQL cheat sheet I could trust, so I built a small Prometheus box and ran every line of one against it. The first surprise came from the latency query everyone copies: histogram_quantile(0.95, ...) on the classic buckets told me the API’s p95 was 95 ms, while the native histogram for the same requests said 1.2 ms. Neither query errored. One of them was simply wrong by a factor of eighty.

Original content from computingforgeeks.com - post 182846

This page is the result: selectors, rate vs irate vs increase, sum by and the other aggregations, vector matching, histograms, the functions I actually use, plus recording and alerting rules checked with promtool check rules and unit tested with promtool test rules. I ran every query here in November 2026 against Prometheus 3.15.1 on an Ubuntu 24.04 VM in my lab, scraping node_exporter 1.12.1 and Prometheus itself, and every output below comes from that box.

PromQL cheat sheet: the quick reference

Every row is the exact query I ran on the lab box, and the last column is the value it returned, rounded, from the first series when a query returned several. The sections after the tables show the full output for the queries that need explaining and where they bite. Metric names come from node_exporter and from Prometheus scraping itself, so you can paste them into your own expression browser unchanged. The two app.jobs.processed rows need my demo exporter, described below. Byte values are raw bytes.

Selectors and matchers

QueryWhat it doesLab result (rounded)
upEvery target, 1 if the last scrape worked, 0 if not1 (first of 3 series)
node_filesystem_avail_bytes{mountpoint="/"}Exact label match16320614400
node_cpu_seconds_total{cpu="0", mode!="idle"}Label does not equalmode=”iowait” 7.39 (first of 7 series)
node_filesystem_size_bytes{fstype=~"ext4|vfat"}Regex match (fully anchored)mountpoint=”/” 19682557952 (first of 3 series)
node_network_receive_bytes_total{device!~"lo|veth.*"}Regex does not matchdevice=”eth0″ 350097151
{__name__=~"node_load(1|5|15)"}Match on the metric name itself0 (first of 3 series)
{"app.jobs.processed"}UTF-8 metric name, quoted inside the braces1407
rate({"app.jobs.processed", "queue.name"="emails"}[1m])UTF-8 name plus a quoted UTF-8 label3

Range vectors, offset and time modifiers

QueryWhat it doesLab result (rounded)
node_load1[1m]Raw samples from the last minute (range vector)4 samples, first 0.01
node_memory_MemAvailable_bytes offset 5mValue as it was 5 minutes ago3530756096
node_load1 @ 1793615400Value at a fixed Unix timestamp (use one inside your own retention)0.02
rate(node_cpu_seconds_total{cpu="0", mode="user"}[5m * 2])Duration expression, no flag needed now0.06809
node_load1 offset (10m / 2)Arithmetic in an offset (parentheses required)0.14
max_over_time(rate(node_network_receive_bytes_total{device="eth0"}[1m])[10m:30s])Subquery: worst 1m rate in the last 10m, sampled every 30s9.898e+04

Counters: rate, irate and increase

QueryWhat it doesLab result (rounded)
rate(node_network_receive_bytes_total{device="eth0"}[5m])Per-second average over the window; use it for alerts and dashboards1728
irate(node_network_receive_bytes_total{device="eth0"}[5m])Per-second rate from the last two samples only; spiky2261
increase(prometheus_http_requests_total{handler="/api/v1/query"}[5m])Total growth over the window (extrapolated)code=”200″ 440 (first of 3 series)
increase(prometheus_http_requests_total{handler="/api/v1/query", code="200"}[5m] anchored)No extrapolation; needs promql-extended-range-selectors559
resets(prometheus_http_requests_total{handler="/api/v1/query", code="200"}[1h])How many times the counter went back to zero0
changes(process_start_time_seconds{job="prometheus"}[1h])Restarts, counted from the start-time gauge0

Aggregation operators

QueryWhat it doesLab result (rounded)
sum by (mode) (rate(node_cpu_seconds_total[5m]))Keep only the listed labelsmode=”idle” 1.956 (first of 8 series)
avg without (cpu) (rate(node_cpu_seconds_total{mode=~"user|system|iowait"}[5m]))Drop the listed labels, keep the restmode=”iowait” 0.0003684 (first of 3 series)
max by (handler) (rate(prometheus_http_requests_total[5m])) > 0Busiest rate per handler, zeros filteredhandler=”/alerts” 0.01402 (first of 9 series)
topk(3, sum by (handler) (rate(prometheus_http_requests_total[5m])))Three largest serieshandler=”/api/v1/query” 1.677 (first of 3 series)
bottomk(2, node_filesystem_avail_bytes{fstype!="tmpfs"})Two smallest seriesmountpoint=”/boot/efi” 102995968 (first of 2 series)
count by (fstype) (node_filesystem_size_bytes)Number of series per label valuefstype=”ext4″ 2 (first of 3 series)
count_values("readonly", node_filesystem_readonly)Count series per sample valuereadonly=”0″ 6
quantile(0.5, rate(node_cpu_seconds_total{mode="user"}[5m]))Median across series (not over time)0.01142
stddev by (mode) (rate(node_cpu_seconds_total{mode=~"user|system"}[5m]))Spread across seriesmode=”system” 0.0003333 (first of 2 series)

Binary operators, vector matching and sets

QueryWhat it doesLab result (rounded)
node_filesystem_avail_bytes{fstype=~"ext4|vfat"} / node_filesystem_size_bytes * 100Percent free; series pair up on identical labelsmountpoint=”/” 82.92 (first of 3 series)
node_memory_MemAvailable_bytes / 1024 / 1024Scalar arithmetic (MiB)3403
node_filesystem_avail_bytes{fstype!="tmpfs"} < 1e9Filter: keep series under 1 GBmountpoint=”/boot/efi” 102995968 (first of 2 series)
node_load1 > bool 0.5Return 1 or 0 instead of filtering0
sum without (code) (rate(prometheus_http_requests_total{code=~"4.."}[5m])) / ignoring (code) rate(prometheus_http_requests_total{code="200"}[5m])Match while ignoring one labelhandler=”/api/v1/query” 0.1972 (first of 2 series)
node_filesystem_avail_bytes{mountpoint="/"} * on (instance) group_left (nodename, release) node_uname_infoMany-to-one join, copy labels from the right sidenodename=”box1″ 16320614400
up{job="missing"} or vector(0)Fall back to 0 when the left side is empty0
node_filesystem_avail_bytes{fstype!="tmpfs"} and node_filesystem_readonly == 0Left series that also exist on the rightmountpoint=”/” 16320614400 (first of 3 series)
up unless up{job="node"}Left series with no match on the rightjob=”prometheus” 1 (first of 2 series)

Histograms

QueryWhat it doesLab result (rounded)
histogram_quantile(0.95, sum by (le) (rate(prometheus_http_request_duration_seconds_bucket[5m])))p95 from classic buckets; always keep le0.095
histogram_quantile(0.95, sum(rate(prometheus_http_request_duration_seconds[5m])))p95 from a native histogram (no _bucket, no le)0.001195
histogram_count(sum(rate(prometheus_http_request_duration_seconds[5m])))Requests per second from a native histogram1.874
histogram_avg(sum(rate(prometheus_http_request_duration_seconds[5m])))Mean observation from a native histogram0.0002864
histogram_fraction(0, 0.0002, sum(rate(prometheus_http_request_duration_seconds[5m])))Share of observations between two values0.7284
sum(rate(prometheus_http_request_duration_seconds_sum[5m])) / sum(rate(prometheus_http_request_duration_seconds_count[5m]))Mean from classic _sum and _count0.0002864

Functions

QueryWhat it doesLab result (rounded)
absent(up{job="mysql"})Returns 1 when nothing matches (alert on missing targets)job=”mysql” 1
absent_over_time(up{job="node"}[5m])Same, over a whole window (empty while the job is up)empty (no series)
predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[10m], 24 * 3600)Linear forecast 24 hours ahead1.342e+10
deriv(node_filesystem_avail_bytes{mountpoint="/"}[10m])Per-second slope of a gauge-3.423e+04
delta(node_memory_MemAvailable_bytes[5m])Change of a gauge over the window (extrapolated)4.307e+07
clamp_max(node_load1 * 100, 50)Cap values (clamp_min, clamp too)0
time() - node_boot_time_secondsUptime in seconds1145
max_over_time(node_load1[10m])Peak of a gauge over time (also min, avg, sum)1.31
quantile_over_time(0.9, node_load1[10m])p90 of one series over time1.241
label_replace(up, "host", "$1", "instance", "(.*):.*")New label from a regex capturehost=”localhost” 1 (first of 3 series)
label_join(up, "target", "/", "job", "instance")New label from joined labelstarget=”node/localhost:9100″ 1 (first of 3 series)
sort_desc(sum by (handler) (rate(prometheus_http_requests_total[5m])))Order the result (instant queries only)handler=”/api/v1/query” 1.969 (first of 64 series)
day_of_week()0 is Sunday; also hour(), day_of_month()1

Ready-made node_exporter queries

QueryWhat it doesLab result (rounded)
100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])))CPU busy percent per host2.186
100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)Memory used percent13.1
100 * (1 - node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"})Root disk used percent17.08
rate(node_network_receive_bytes_total{device="eth0"}[5m]) * 8 / 1e6Inbound Mbit/s0.01382
sum(rate(prometheus_http_requests_total{code=~"4..|5.."}[5m])) / sum(rate(prometheus_http_requests_total[5m]))Error ratio (4xx and 5xx)0.1526
sum(rate(prometheus_http_request_duration_seconds_bucket{le="0.1"}[5m])) / sum(rate(prometheus_http_request_duration_seconds_count[5m]))Latency SLO: share of requests under 100 ms1

One row in those tables needs a feature flag (anchored), and so do the experimental functions further down. The native-histogram rows need no flag but do need scrape_native_histograms: true on the scraped target, as in the lab config in the next section, or they come back empty. Everything else works on a default Prometheus 3 install with no flags, including the quoted UTF-8 selectors and the duration arithmetic.

Setting up the box I queried

If you already run Prometheus, you don’t need my file, but add the two histogram keys shown below to your existing prometheus job, or every native-histogram query on this page comes back empty. Your job names may differ too: our install guide calls the node job node_exporter where I use node. I installed the release tarballs the same way our Prometheus 3 install guide for Ubuntu and Debian does (there is also a separate walkthrough to install Prometheus on Ubuntu 26.04), then added three scrape jobs: node_exporter on 9100, Prometheus itself on 9090, and a tiny exporter on 9200 that serves a metric with dots in its name. The part that matters for this page is the prometheus job, because it turns on native histograms for that one target:

sudo vim /etc/prometheus/prometheus.yml

This is the exact file the lab ran with, unchanged from the box.

global:
  scrape_interval: 15s
  evaluation_interval: 15s

rule_files:
  - /etc/prometheus/rules/*.yml

scrape_configs:
  - job_name: prometheus
    scrape_native_histograms: true
    always_scrape_classic_histograms: true
    static_configs:
      - targets: ["localhost:9090"]

  - job_name: node
    static_configs:
      - targets: ["localhost:9100"]

  - job_name: app
    static_configs:
      - targets: ["localhost:9200"]

scrape_native_histograms: true is the switch for native histograms now, and the old native-histograms feature flag does nothing on current releases. I also kept always_scrape_classic_histograms: true so the same requests land in both formats, which is how I could compare them side by side. Drop it on a real server once your dashboards have moved: the classic format costs a series per bucket (twelve per handler here, counting _sum and _count), while the native one is a single series.

Every query output on this page came from promtool query instant, which ships in the Prometheus tarball and talks to the HTTP API. It is quicker than the browser for checking one expression, and you can paste the result into a ticket:

promtool query instant http://localhost:9090 'up'

All three targets answered, and the number after @ is the evaluation time as a Unix timestamp.

up{instance="localhost:9100", job="node"} => 1 @[1793615474.885]
up{instance="localhost:9090", job="prometheus"} => 1 @[1793615474.885]
up{instance="localhost:9200", job="app"} => 1 @[1793615474.885]

One thing promtool won’t show you is PromQL’s info and warning annotations. They only come back in the JSON from /api/v1/query, which matters for the rate-on-a-gauge mistake covered in the errors section.

Selecting series with label matchers

A selector is a metric name plus label matchers. There are four matcher operators:

  • = equals, != does not equal
  • =~ matches a regex, !~ does not match it

Regex matchers are anchored at both ends, so fstype=~"ext" matches nothing on an ext4 box. You need "ext.*" or the full value. The regex form against my filesystems shows it:

promtool query instant http://localhost:9090 'node_filesystem_size_bytes{fstype=~"ext4|vfat"}'

Three filesystems matched, the tmpfs mounts didn’t, and each series still carries every label node_exporter attached:

node_filesystem_size_bytes{device="/dev/sda1", fstype="ext4", instance="localhost:9100", job="node", mountpoint="/"} => 19682557952 @[1793615474.999]
node_filesystem_size_bytes{device="/dev/sda15", fstype="vfat", instance="localhost:9100", job="node", mountpoint="/boot/efi"} => 109395456 @[1793615474.999]
node_filesystem_size_bytes{device="/dev/sda16", fstype="ext4", instance="localhost:9100", job="node", mountpoint="/boot"} => 923156480 @[1793615474.999]

Metric names with dots are where Prometheus 3 changed the syntax. UTF-8 names are on by default, but a name that isn’t a legal classic identifier has to be quoted and moved inside the braces. I first tried the obvious form and got a parse error that points at the dot:

query error: bad_data: invalid parameter "query": 1:9: parse error: unexpected character: '.'

The quoted form works, and quoted label names with dots work the same way:

promtool query instant http://localhost:9090 'rate({"app.jobs.processed", "queue.name"="emails"}[1m])'

My demo exporter adds 3 jobs a second, and the rate agrees with it.

{instance="localhost:9200", job="app", queue.name="emails"} => 2.9999999999999996 @[1793615475.158]

Getting that metric into Prometheus was harder than querying it. My first attempt dropped it into node_exporter’s textfile collector, and node_exporter refused the file twice: once with invalid metric name in comment for the unquoted # HELP line, then with invalid metric name "app.jobs.processed" once I quoted it. I gave up on the textfile route and served the metric from a 15-line Python exporter instead. If you’re planning dotted names from OpenTelemetry, check that every hop in your pipeline accepts them before you rename anything.

Choosing between rate, irate and increase

All three take a counter and a range, and they answer different questions. rate() is the per-second average over the whole window, irate() uses only the last two samples in it, and increase() is the total growth over the window. I ran them back to back on the same interface:

promtool query instant http://localhost:9090 'rate(node_network_receive_bytes_total{device="eth0"}[5m])'
promtool query instant http://localhost:9090 'irate(node_network_receive_bytes_total{device="eth0"}[5m])'
promtool query instant http://localhost:9090 'increase(prometheus_http_requests_total{handler="/api/v1/query"}[5m])'

The terminal below has all five commands from this section with their output, including the two short-window runs further down:

promtool query output comparing rate, irate and increase on Prometheus 3 plus an empty 15s rate window

Same five minutes, same counter, and rate said 1,728 bytes/s while irate said 2,261. Which one is right? Neither is wrong. irate just reported the last 15 seconds, which happened to be busier. That’s why I keep irate for zoomed-in graphs and never alert on it: one quiet or busy scrape flips the answer. The increase result also had a fraction in it (63.1578947368421 requests with a 400 code), which surprises people the first time. Counters only grow in whole numbers, but increase extrapolates to the edges of the window, so fractions are normal.

A graph shows the difference better than single numbers. I ran a 6-second CPU burst every 46 seconds and plotted both functions in one panel, using label_replace to name each line and or to join them. Paste this into the query page’s Graph tab:

label_replace(sum(rate(node_cpu_seconds_total{mode="user"}[5m])), "query", "rate 5m", "", "")
or
label_replace(sum(irate(node_cpu_seconds_total{mode="user"}[5m])), "query", "irate 5m", "", "")

Fifteen minutes of it, with the bursts starting at 10:35:

Prometheus 3 query page graphing rate and irate of node_cpu_seconds_total during CPU bursts

The green irate line jumps to about 0.4 CPU-seconds per second on most bursts and falls back to almost zero between them, and a few bursts show up at half height because they straddled two scrapes. The 5-minute rate climbs slowly and settles near 0.13. Both are correct. If an alert has to catch a 6-second burst, neither line is the answer. A shorter window or max_over_time on a subquery is.

How short can the range be?

Prometheus 3 made range selectors left-open: a sample sitting exactly on the window’s start no longer counts. With my 15-second scrape interval, a [15s] window holds one sample, and a rate needs two. The query doesn’t fail, it just returns nothing:

promtool query instant http://localhost:9090 'rate(node_cpu_seconds_total{cpu="0", mode="user"}[15s])'
promtool query instant http://localhost:9090 'rate(node_cpu_seconds_total{cpu="0", mode="user"}[30s])'

The first command printed an empty line and exited 0, while the second gave a value.

{cpu="0", instance="localhost:9100", job="node", mode="user"} => 0.006000000000000227 @[1793615475.439]

Two scrape intervals is the floor, and I’d use four in anything you alert on, so one missed scrape doesn’t blank the panel. In Grafana, $__rate_interval does that arithmetic for you, at least four times the scrape interval, as long as the scrape interval set on the data source matches the real one.

Whole-number increases with anchored

The anchored modifier stops the extrapolation, so increase() returns whole numbers. On the current release it sits behind --enable-feature=promql-extended-range-selectors, and without the flag the parser says anchored modifier is experimental and not enabled. With the flag on, I ran both forms 40 ms apart:

promtool query instant http://localhost:9090 'increase(prometheus_http_requests_total{handler="/api/v1/query", code="200"}[5m] anchored)'
promtool query instant http://localhost:9090 'increase(prometheus_http_requests_total{handler="/api/v1/query", code="200"}[5m])'

Anchored came back as 559 and the plain one as 572.63.

{code="200", handler="/api/v1/query", instance="localhost:9090", job="prometheus"} => 559 @[1793615581.703]
{code="200", handler="/api/v1/query", instance="localhost:9090", job="prometheus"} => 572.6315789473684 @[1793615581.743]

For billing-style counts and “how many requests failed today” panels, the whole number is what people expect to see. Its sibling, smoothed (as in rate(x[5m] smoothed)), sits behind the same promql-extended-range-selectors flag, but I did not run a smoothed query on the lab box, so there is no output for it here. The release candidate for the next minor version lists anchored and smoothed selectors as stable, so the flag should go away soon, though I haven’t tested that build either.

Aggregating with sum by and without

by keeps only the labels you list, while without drops the ones you list and keeps the rest. I reach for without in recording rules, because labels like instance and job survive automatically, and by in dashboards where I want exactly one line per value. This is CPU time per mode across both cores:

promtool query instant http://localhost:9090 'sum by (mode) (rate(node_cpu_seconds_total[5m]))'

The values are CPU-seconds per second, so idle near 2.0 on a 2-core VM means the box was almost asleep:

{mode="idle"} => 1.9562807017543857 @[1793615475.479]
{mode="iowait"} => 0.0007368421052631577 @[1793615475.479]
{mode="irq"} => 0 @[1793615475.479]
{mode="nice"} => 0 @[1793615475.479]
{mode="softirq"} => 0.0003157894736842106 @[1793615475.479]
{mode="steal"} => 0.00038596491228070164 @[1793615475.479]
{mode="system"} => 0.015824561403508762 @[1793615475.479]
{mode="user"} => 0.022842105263157858 @[1793615475.479]

The order of operations matters more than the syntax. Always rate() first and sum() second, because summing raw counters and then taking a rate breaks as soon as one of them resets. For “top N” views, topk on the aggregated result is what you want:

promtool query instant http://localhost:9090 'topk(3, sum by (handler) (rate(prometheus_http_requests_total[5m])))'

My load script hammered /api/v1/query, so it tops the list.

{handler="/api/v1/query"} => 1.6771929824561402 @[1793615475.636]
{handler="/api/v1/query_range"} => 0.1087719298245614 @[1793615475.636]
{handler="/metrics"} => 0.06666666666666667 @[1793615475.636]

Without the topk, a plain max by (handler) returned 64 rows, most of them 0 for handlers nobody had called. Appending > 0 cut it to the nine that had traffic, which is the quickest cleanup for a noisy table panel. Remember too that quantile() works across series at one instant. For a percentile of one series over time you want quantile_over_time().

Matching two vectors with on, ignoring and group_left

A binary operator between two vectors pairs series whose labels are identical. When they aren’t, you tell it which labels to compare with on() or ignoring(), and when one side has several series per match, you say which side with group_left or group_right. The common case is copying a label from an info metric:

promtool query instant http://localhost:9090 'node_filesystem_avail_bytes{mountpoint="/"} * on (instance) group_left (nodename, release) node_uname_info'

node_uname_info is always 1, so the value is unchanged and the result gains the host name and kernel:

{device="/dev/sda1", fstype="ext4", instance="localhost:9100", job="node", mountpoint="/", nodename="box1", release="6.8.0-106-generic"} => 16320614400 @[1793615476.08]

My first try at a 4xx-to-200 ratio per handler failed. I wrote rate(...{code=~"4.."}) / ignoring (code) rate(...{code="200"}), and the left side had two series per handler (400 and 422), so Prometheus refused with many-to-one matching must be explicit (group_left/group_right). Summing the codes away on the left first fixed it:

promtool query instant http://localhost:9090 'sum without (code) (rate(prometheus_http_requests_total{code=~"4.."}[5m])) / ignoring (code) rate(prometheus_http_requests_total{code="200"}[5m])'

The query_range handler shows 15.5 errors per success because my load script sends it a broken start parameter on purpose:

{handler="/api/v1/query", instance="localhost:9090", job="prometheus"} => 0.19718309859154928 @[1793615971.124]
{handler="/api/v1/query_range", instance="localhost:9090", job="prometheus"} => 15.522300371533767 @[1793615971.124]

Set operators skip the value maths and only compare label sets. The three behave like this in the lab:

  • or vector(0): keeps a panel at 0 instead of “No data”
  • and: kept the three writable filesystems
  • unless: up unless up{job="node"} left the prometheus and app targets

With up{job="missing"} or vector(0) the lab returned {} => 0, an empty label set, which is fine for a single stat panel and useless anywhere you need to group by a label afterwards.

Reading histograms: classic buckets vs native

This is the finding from the intro, and the reason I’d check every latency panel you own. Prometheus exposes its own HTTP request duration as both a classic histogram (the _bucket series with an le label) and a native histogram (one series, no le). I asked both for the p95 of the same requests over the same 5 minutes, then asked for the mean:

promtool query instant http://localhost:9090 'histogram_quantile(0.95, sum by (le) (rate(prometheus_http_request_duration_seconds_bucket[5m])))'
promtool query instant http://localhost:9090 'histogram_quantile(0.95, sum(rate(prometheus_http_request_duration_seconds[5m])))'
promtool query instant http://localhost:9090 'histogram_avg(sum(rate(prometheus_http_request_duration_seconds[5m])))'

Classic said 95 ms, native said 1.19 ms, and the mean of the same requests was 0.29 ms.

histogram_quantile p95 on classic buckets returning 0.095 against 0.0012 from the native histogram in Prometheus 3

So which number is real? The classic one is an artifact. This histogram’s smallest bucket is le="0.1", and every request in the window fell into it, since that bucket divided by _count returned exactly 1. histogram_quantile assumes observations are spread evenly inside a bucket, so the 95th percentile of “somewhere between 0 and 100 ms” comes out as 95 ms, no matter how fast the requests actually were. Native histograms use exponential buckets that are fine enough at sub-millisecond scale to give a believable answer. If your classic p95 lands on a round fraction of a bucket’s upper bound, like 0.095 against le="0.1", you’re reading the bucket layout, not your latency.

Classic histograms stay honest only if you respect how the function reads them, because histogram_quantile works from bucket counts and nothing else. What I check on every classic latency query now:

  • sum by (le) in the aggregation, plus any split label such as handler
  • Never sum without (le): the quantile needs the buckets
  • A bucket boundary near the SLO, since the answer can’t be finer than the buckets
  • histogram_avg or _sum over _count next to it as a sanity check

Native histograms drop the le step entirely, and they add functions the classic format can’t do directly:

promtool query instant http://localhost:9090 'histogram_fraction(0, 0.0002, sum(rate(prometheus_http_request_duration_seconds[5m])))'

About 73% of requests finished in under 200 microseconds.

{} => 0.7284007851537232 @[1793615476.468]

For a classic-bucket SLO the equivalent is one bucket divided by _count, as in the ready-made table. On my box le="0.1" divided by the count returned exactly 1, which is true and also tells you nothing, for the same bucket-layout reason.

Using the functions I reach for most

Most of the function list is niche. These are the ones that show up in my alerts and dashboards, with what each returned in the lab:

  • absent(up{job="mysql"}): {job="mysql"} => 1, so an alert fires when a whole job disappears
  • predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[10m], 24 * 3600): 13.4 GB left in 24 h, down from 16.3 GB, while a test loop filled the disk
  • deriv(...[10m]) on the same series: -34,232 bytes per second
  • time() - node_boot_time_seconds: 1,144.8 seconds of uptime
  • label_replace(up, "host", "$1", "instance", "(.*):.*"): added host="localhost" to all three targets
  • max_over_time(rate(node_network_receive_bytes_total{device="eth0"}[1m])[10m:30s]): peak 1-minute rate of 98,979 bytes/s in the last 10 minutes

The subquery is worth learning. A plain 5-minute rate hides short bursts, and the subquery answers “what was the worst minute in the last ten” without a recording rule. Use deriv and delta on gauges only, because on counters they ignore resets and give nonsense after a restart.

Duration arithmetic now works without a flag. rate(node_cpu_seconds_total[5m * 2]) and offset (10m / 2) both ran on a stock install, which is handy in templated queries. The experimental functions are a different story, because they stay behind --enable-feature=promql-experimental-functions. The ones I tried with the flag on:

  • limitk(2, ...): two arbitrary series, cheaper than topk
  • sort_by_label(..., "mountpoint"): rows in label order
  • mad_over_time(node_load1[15m]): median absolute deviation, 0.165
  • ts_of_max_over_time(node_load1[15m]): Unix time of the peak
  • double_exponential_smoothing(...[10m], 0.3, 0.3): the renamed holt_winters

Without the flag, Prometheus says exactly which one it needs:

query error: bad_data: invalid parameter "query": 1:1: parse error: limitk() is experimental and must be enabled with --enable-feature=promql-experimental-functions

If you have old dashboards using holt_winters, they break on Prometheus 3 even with the flag. It was renamed to double_exponential_smoothing, and the old name now fails with unknown function with name "holt_winters". To turn the flags on, edit your existing unit rather than replacing it, because the unit from our install guide also sets retention, the listen address and the OTLP receiver, and losing --storage.tsdb.retention.time drops you back to the 15-day default on the next restart:

sudo vim /etc/systemd/system/prometheus.service

Append this one argument to the end of your ExecStart line. If the line is split, end the previous line with a \ first:

--enable-feature=promql-experimental-functions,promql-extended-range-selectors

For reference, this was the whole unit on my lab box, which is deliberately minimal. Don’t paste it over a production unit:

[Unit]
Description=Prometheus
Wants=network-online.target
After=network-online.target

[Service]
User=prometheus
ExecStart=/usr/local/bin/prometheus \
  --config.file=/etc/prometheus/prometheus.yml \
  --storage.tsdb.path=/var/lib/prometheus \
  --web.enable-lifecycle \
  --enable-feature=promql-experimental-functions,promql-extended-range-selectors
Restart=on-failure

[Install]
WantedBy=multi-user.target

Reload systemd and restart the service. The journal confirms both flags took effect:

sudo systemctl daemon-reload
sudo systemctl restart prometheus
journalctl -u prometheus --no-pager | grep -i "enabled"

Both lines appeared right after the restart.

Nov 02 10:32:40 box1 prometheus[53209]: time=2026-11-02T10:32:40.024Z level=INFO source=main.go:263 msg="Experimental PromQL functions enabled."
Nov 02 10:32:40 box1 prometheus[53209]: time=2026-11-02T10:32:40.024Z level=INFO source=main.go:316 msg="Experimental PromQL extended range selectors enabled."

Experimental means the names and behaviour can change between releases, so I would keep these out of alert rules and use them in ad hoc queries until they graduate.

Writing recording rules

A recording rule evaluates an expression on a schedule and stores the result as a new series. I use them for anything a dashboard or alert queries over and over, and for expressions long enough that I don’t want them copied into five places. The naming convention is level:metric:operations. Create the file:

sudo mkdir -p /etc/prometheus/rules
sudo vim /etc/prometheus/rules/recording.yml

These four rules are the exact file I tested. The CPU rule drops both cpu and mode, so the result carries only instance and job:

groups:
  - name: node-recording
    interval: 30s
    rules:
      - record: instance:node_cpu_utilisation:rate5m
        expr: 1 - avg without (cpu, mode) (rate(node_cpu_seconds_total{mode="idle"}[5m]))

      - record: instance:node_memory_utilisation:ratio
        expr: 1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes

      - record: job:prometheus_http_requests:rate5m
        expr: sum by (job, code) (rate(prometheus_http_requests_total[5m]))

      - record: job:prometheus_http_request_duration_seconds:p95_5m
        expr: histogram_quantile(0.95, sum by (job, le) (rate(prometheus_http_request_duration_seconds_bucket[5m])))

My first version used avg without (cpu), which left a stray mode="idle" label on the recorded series. It worked, but every alert built on it inherited that label. Prometheus picks up rule files on a reload. My unit runs with --web.enable-lifecycle, so the HTTP endpoint works; without that flag, use sudo systemctl restart prometheus instead:

curl -s -X POST http://localhost:9090/-/reload
promtool query instant http://localhost:9090 'instance:node_cpu_utilisation:rate5m'

The recorded series is queryable like any other, carrying only instance and job:

instance:node_cpu_utilisation:rate5m{instance="localhost:9100", job="node"} => 0.07226315789473658 @[1793616873.813]

The last rule records the classic p95, and it dutifully stored 0.09500000000000001. A recording rule makes a bad query cheaper, not correct, so fix the histogram first.

Writing alert rules and testing them with promtool

Alert rules add for (how long the condition must hold before it fires), labels for routing and annotations for the message. keep_firing_for holds an alert open for a while after the condition clears, which stops a flapping CPU alert from paging you three times in ten minutes. Create the alerts file:

sudo vim /etc/prometheus/rules/alerts.yml

These are the four alerts from the lab, unchanged. The CPU alert builds on the recording rule above, and the disk alert combines a forecast with a floor, so a disk that’s 90% free never pages you just because a log rotated:

groups:
  - name: node-alerts
    rules:
      - alert: InstanceDown
        expr: up == 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "{{ $labels.instance }} ({{ $labels.job }}) is down"
          description: "The target has failed every scrape for 2 minutes."

      - alert: HighCpuUsage
        expr: instance:node_cpu_utilisation:rate5m > 0.85
        for: 10m
        keep_firing_for: 5m
        labels:
          severity: warning
        annotations:
          summary: "CPU on {{ $labels.instance }} at {{ $value | humanizePercentage }}"

      - alert: DiskWillFillIn24h
        expr: |
          predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}[6h], 24 * 3600) < 0
          and
          node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes < 0.25
        for: 30m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.mountpoint }} on {{ $labels.instance }} fills within 24h"

      - alert: HighHttpErrorRatio
        expr: |
          sum by (job) (rate(prometheus_http_requests_total{code=~"5.."}[5m]))
          /
          sum by (job) (rate(prometheus_http_requests_total[5m])) > 0.05
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.job }} 5xx ratio is {{ $value | humanizePercentage }}"

Unit tests live in their own directory. My first run put the test file in rules/, where the rule_files glob would have tried to load it as a rule file and failed the next reload, so keep it out of that path:

sudo mkdir -p /etc/prometheus/tests
sudo vim /etc/prometheus/tests/alerts_test.yml

The test feeds synthetic series in and states what should fire and when. The second case checks the recording rule’s value (an idle counter growing 6 seconds per minute means 90% busy) and the rendered annotation:

rule_files:
  - ../rules/recording.yml
  - ../rules/alerts.yml

evaluation_interval: 1m

tests:
  - interval: 1m
    input_series:
      - series: 'up{job="node", instance="box2:9100"}'
        values: '1 1 0 0 0 0'
    alert_rule_test:
      - eval_time: 3m
        alertname: InstanceDown
        exp_alerts: []
      - eval_time: 5m
        alertname: InstanceDown
        exp_alerts:
          - exp_labels:
              severity: critical
              job: node
              instance: box2:9100
            exp_annotations:
              summary: "box2:9100 (node) is down"
              description: "The target has failed every scrape for 2 minutes."

  - interval: 1m
    input_series:
      - series: 'node_cpu_seconds_total{instance="box1:9100", cpu="0", mode="idle"}'
        values: '0+6x30'
    promql_expr_test:
      - expr: instance:node_cpu_utilisation:rate5m
        eval_time: 10m
        exp_samples:
          - labels: 'instance:node_cpu_utilisation:rate5m{instance="box1:9100"}'
            value: 0.9
    alert_rule_test:
      - eval_time: 25m
        alertname: HighCpuUsage
        exp_alerts:
          - exp_labels:
              severity: warning
              instance: box1:9100
            exp_annotations:
              summary: "CPU on box1:9100 at 90%"

Check the syntax of both rule files, then run the tests. The cd is only there for the short paths on the command line, because promtool resolves the test file’s rule_files relative to the test file itself:

cd /etc/prometheus
promtool check rules rules/recording.yml rules/alerts.yml
promtool test rules tests/alerts_test.yml

Both files parsed with four rules each, and the tests passed.

promtool check rules finding 4 recording and 4 alerting rules and promtool test rules returning SUCCESS

Would the test catch a wrong expectation? I changed the expected summary to 95% and ran it again:

promtool test rules FAILED output showing expected and got alerts for HighCpuUsage

The diff shows the expected and actual alert side by side, and the command exited 1, so it works as a CI gate. Put promtool check rules and promtool test rules in the pipeline that ships your rule files, and a typo never reaches a reload. A syntax error looks like this, with the line, the rule and the column:

Checking /tmp/broken.yml
  FAILED:
/tmp/broken.yml: 5:15: group "broken", rule 1, "LoadHigh": could not parse expression: 1:24: parse error: unclosed left parenthesis

To see a real alert fire, I stopped the demo exporter on port 9200. InstanceDown went to pending on the next evaluation and to firing two minutes later. Open Alerts in the Prometheus UI and expand the rule:

Prometheus 3.15.1 Alerts page with the InstanceDown rule firing for the stopped app target
  1. 1InstanceDown rule, firing
  2. 2The down target and its labels
  1. InstanceDown rule, firing: the rule row turns red with a firing count.
  2. The down target and its labels: the alert carries instance="localhost:9200" and job="app", the labels Alertmanager routes on.

The rule panel shows the raw annotation template, and the rendered summary only appears in the alert itself and in Alertmanager. Querying ALERTS gives the same state from PromQL, which is what I use in Grafana panels that list everything currently firing across a fleet.

Fixing common PromQL errors

These came up in the lab, roughly in the order I hit them while building this page. The two vector errors are in the capture below, and the rest are quoted as Prometheus returned them.

PromQL errors many-to-one matching must be explicit and vector cannot contain metrics with the same labelset

Each heading below is the message as you’ll see it, followed by the cause and the fix I used.

vector cannot contain metrics with the same labelset

I got this from label_replace(rate(node_cpu_seconds_total{mode="idle"}[5m]), "cpu", "all", "cpu", ".*"). Rewriting cpu to one value made both cores’ series identical, and a result can’t hold two series with the same labels. The same error shows up when a function drops __name__ from a selector that matched several metrics, such as rate({__name__=~"node_network_(receive|transmit)_bytes_total"}[5m]). Aggregate first so only one series is left per label set:

promtool query instant http://localhost:9090 'label_replace(sum without (cpu) (rate(node_cpu_seconds_total{mode="idle"}[5m])), "cpu", "all", "", "")'

That leaves one series per mode, now labelled cpu="all".

{cpu="all", instance="localhost:9100", job="node", mode="idle"} => 1.8510526315789462 @[1793615971.162]

For the multi-metric case, query each metric separately or sum by (__name__) before anything strips the name.

many-to-one matching must be explicit (group_left/group_right)

node_filesystem_avail_bytes * on (instance) node_uname_info fails because the left side has one series per filesystem and the right side one per host. Add group_left (the many side is on the left) and name the labels to copy, as in the vector matching section. If both sides have several series per match, that’s many-to-many and no modifier fixes it: aggregate one side down first.

PromQL info: metric might not be a counter

rate(node_load1[5m]) returned a number and promtool printed no warning, but load average is a gauge and its rate means nothing. The API flags it:

curl -s localhost:9090/api/v1/query --data-urlencode 'query=rate(node_load1[5m])' | python3 -m json.tool

The result is a value plus an infos entry that nobody sees unless they look at the JSON:

{
    "status": "success",
    "data": {
        "resultType": "vector",
        "result": [
            {
                "metric": {
                    "instance": "localhost:9100",
                    "job": "node"
                },
                "value": [
                    1793615954.048,
                    "0.005778725614035086"
                ]
            }
        ]
    },
    "infos": [
        "PromQL info: metric might not be a counter, name does not end in _total/_sum/_count/_bucket: \"node_load1\" (1:6)"
    ]
}

Use deriv() or delta() for gauges. The check is name-based, though: my real counter app.jobs.processed got the same info because its name doesn’t end in _total. Treat it as a hint, not proof.

An empty result with no error

The quiet one. A range window shorter than two scrape intervals ([15s] at a 15-second scrape) returns nothing and exits 0. So do typos in label values, since a selector that matches no series isn’t an error. When a panel says “No data”, run the bare selector without functions first. If that’s empty too, the problem is the labels, not the maths.

parse error: unexpected character: ‘.’

This is an unquoted UTF-8 metric name, and the fix is to quote it and put it inside the braces, as in {"app.jobs.processed"}.

If you want the theory behind all of this, Prometheus: Up & Running (2nd edition) (paid link) by Julien Pivotto and Brian Brazil is the book I’d hand a new on-call engineer. It covers PromQL, rules and Alertmanager in more depth than any cheat sheet can, though it predates Prometheus 3, so UTF-8 names and stable native histograms come from this page and the docs. Our roundup of Prometheus and Grafana books compares it with the alternatives. For exporters to point these queries at, the node_exporter queries above follow the same patterns as our guide to monitoring Linux server uptime and health. The MySQL and MariaDB exporter setup, the Redis monitoring guide, the Nginx monitoring guide, the PostgreSQL exporter setup and the Docker container stack with cAdvisor all feed the same patterns, and on Kubernetes the kube-prometheus-stack install ships recording rules in exactly this level:metric:operations style. The full function list is in the official PromQL functions reference, and the Prometheus 3 migration guide lists every query behaviour that changed.

What I still don’t know

I haven’t run native histograms under real production load, so I can’t tell you what the extra bucket resolution costs in TSDB size on a busy server. On this box the difference was lost in the noise. I also don’t know whether node_exporter’s textfile collector will learn UTF-8 names, or whether a flag I missed already allows them. And I’d like to know how many teams are alerting on a classic p95 that is really just a bucket boundary. After this lab, my first move on any inherited Prometheus is to check every latency panel against histogram_avg and the bucket layout, because a number that looks plausible is the hardest kind of wrong to notice.

Keep reading

Claude Code Cheat Sheet: Commands, Shortcuts and Flags (PDF) AI Claude Code Cheat Sheet: Commands, Shortcuts and Flags (PDF) OpenAI Codex CLI Cheat Sheet: Shortcuts, Commands, Flags (PDF) AI OpenAI Codex CLI Cheat Sheet: Shortcuts, Commands, Flags (PDF) macOS Keyboard Shortcuts Cheat Sheet (Golden Gate and Tahoe) Cheat Sheets macOS Keyboard Shortcuts Cheat Sheet (Golden Gate and Tahoe) APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian Cheat Sheets APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian Windows 11 Keyboard Shortcuts Cheat Sheet (Printable PDF) Cheat Sheets Windows 11 Keyboard Shortcuts Cheat Sheet (Printable PDF) How To Install Cacti Spine poller on Ubuntu 20.04|18.04 Monitoring How To Install Cacti Spine poller on Ubuntu 20.04|18.04

Leave a Comment

Press ESC to close