Four metric types, and the consequences of choosing wrong

Prometheus has a small type system, and nearly every misleading graph in production comes from picking the wrong member of it.

TypeSemanticsUse forQuery with
CounterMonotonically increasing; resets to 0 on process restartRequests served, bytes sent, errors, jobs completedrate(), increase() — never the raw value
GaugeArbitrary value that goes up and downQueue depth, temperature, in-flight requests, memory in useRaw value, avg_over_time, delta(), deriv()
HistogramCumulative bucket counts plus sum and countLatency, response size — anything you need percentiles ofhistogram_quantile() over rate() of buckets
SummaryClient-side quantiles plus sum and countSingle-instance quantiles where aggregation is not neededDirect read — but it cannot be aggregated across instances

Counters are named with a _total suffix by convention and must only ever increase. If you find yourself wanting to decrement a counter, you want a gauge. If you find yourself wanting a percentile from a gauge, you want a histogram.

The counter reset problem, and why rate() exists

A counter goes back to zero when the process restarts. Naively subtracting two samples would produce a large negative number every deploy. rate() and increase() detect resets and compensate, which is the entire reason you must never graph a counter's raw value.

rate, irate, and increase

The three of them

# rate() - per-second average over the window, extrapolated to the
# window edges, reset-aware. This is your default.
rate(http_requests_total[5m])

# increase() - the total change over the window.
# Exactly equivalent to rate(x[5m]) * 300, with the same extrapolation.
increase(http_requests_total[1h])

# irate() - instantaneous rate from the last TWO samples only.
# Very responsive, very noisy, and it will miss spikes entirely when
# the graph resolution is coarser than the sample interval.
# Use for high-resolution debugging graphs, never for alerts.
irate(http_requests_total[5m])

Rule of thumb for the window: at least four times your scrape interval. With a 15s scrape, [1m] is the practical minimum and [5m] is a safer default. Too short and a single missed scrape produces gaps; too long and you smooth away the incident you are trying to see.

Extrapolation surprises

rate() and increase() extrapolate to the window boundaries, which means increase() on a slow-moving counter can return non-integer values like 3.7 events. That is expected behaviour, not a bug. If you need exact counts for billing or reporting, query the raw counter difference rather than using increase().

Aggregation order is not optional

This is the single most common PromQL error, and it produces answers that are wrong rather than obviously broken.

rate before sum

# WRONG - sums the raw counters, then rates the sum.
# Every process restart in the fleet becomes a phantom negative spike,
# and the reset correction cannot work because it has been applied
# to an aggregate that is not a counter.
rate(sum(http_requests_total)[5m:])

# CORRECT - rate each series, then sum the rates.
sum(rate(http_requests_total[5m]))

# The rule: rate() FIRST, then sum(). Always.
# Same for increase(), irate(), delta(), and deriv().

Ratio of sums, not average of ratios

# WRONG - averages ratios, weighting a 3am hour equally with noon
avg(
  rate(http_requests_total{code=~"5.."}[5m])
  / rate(http_requests_total[5m])
)

# CORRECT - ratio of sums, traffic-weighted
sum(rate(http_requests_total{code=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))

# The rule: sum the numerator, sum the denominator, then divide.

Label matching in binary operations

When you combine two metrics, Prometheus matches series by their full label sets. Any label present on one side and not the other causes the match to fail silently and return no data, which is the second most common source of empty panels.

Vector matching

# Fails: http_requests_total has code, method, route;
# the denominator after sum() has none of them.
rate(http_requests_total{code=~"5.."}[5m]) / sum(rate(http_requests_total[5m]))

# Works: both sides are aggregated to the same label set
sum by (route) (rate(http_requests_total{code=~"5.."}[5m]))
/
sum by (route) (rate(http_requests_total[5m]))

# When the label sets genuinely differ, be explicit:
#   on(...)     - match only on these labels
#   ignoring(...) - match on everything except these
#   group_left  - many-to-one: extra labels come from the LEFT side
#   group_right - one-to-many: extra labels come from the RIGHT side

# Classic use: join a metric with a metadata / "info" metric
sum by (pod) (rate(container_cpu_usage_seconds_total[5m]))
  * on(pod) group_left(app, team)
    kube_pod_labels

An empty Grafana panel is far more often a label-matching failure than a missing metric. Run each side of the division on its own in the query browser and compare their label sets before assuming the data is not there.

Recording rules: precompute what you query often

Any expression that appears on a dashboard many people load, or inside an alert evaluated every fifteen seconds, should be a recording rule. This moves the cost from query time to ingest time, gives every consumer identical arithmetic, and keeps dashboards fast when the range is a month.

prometheus/rules/red.yml

groups:
  - name: service-red
    interval: 30s
    rules:
      # Naming convention: level:metric:operations
      #   level      - the aggregation level (job, route, cluster)
      #   metric     - the underlying metric name
      #   operations - what was applied, most recent first

      - record: job:http_requests:rate5m
        expr: sum by (job) (rate(http_requests_total[5m]))

      - record: job:http_requests_errors:rate5m
        expr: sum by (job) (rate(http_requests_total{code=~"5.."}[5m]))

      - record: job:http_error_ratio:rate5m
        expr: |
          job:http_requests_errors:rate5m / job:http_requests:rate5m

      - record: job_le:http_request_duration_seconds_bucket:rate5m
        expr: |
          sum by (job, le) (rate(http_request_duration_seconds_bucket[5m]))

      - record: job:http_request_duration_seconds:p99
        expr: |
          histogram_quantile(0.99, job_le:http_request_duration_seconds_bucket:rate5m)
  • Rules within a group evaluate sequentially, so a later rule can safely depend on an earlier one in the same group.
  • Groups evaluate in parallel with each other, so a rule must never depend on a different group.
  • Set interval no shorter than your scrape interval; there is nothing to gain from evaluating faster than data arrives.
  • Keep the naming convention rigid. level:metric:operations makes it obvious at a glance whether a series is raw or derived.

Choosing a good metric name

Naming that survives contact with a second team

# Convention:  <namespace>_<subsystem>_<name>_<unit>[_total]

http_requests_total                      # counter, no unit needed
http_request_duration_seconds            # histogram, base unit seconds
http_response_size_bytes                 # histogram, base unit bytes
process_resident_memory_bytes            # gauge
queue_depth                              # gauge
kafka_consumer_lag_messages              # gauge with an explicit unit

# Anti-patterns seen in the wild:
api_latency_ms          # use base units: seconds, not milliseconds
requestCount            # use snake_case
http_requests           # counters should end in _total
errors                  # namespace it: myapp_http_errors_total
cpu_usage_percent       # use a ratio 0-1, or expose the raw counter

Always use base units — seconds and bytes — and let the dashboard handle presentation. Mixed units across services turn every cross-service query into a unit-conversion puzzle, and someone will eventually get it wrong during an incident.

Queries that will bite you

PatternProblemInstead
sum(rate(x[5m])) with no byCollapses everything into one number; you lose the ability to see which instance or route is at faultAggregate to the level you need: sum by (route)
rate(x[1m]) with a 60s scrapeOnly one sample in the window; returns empty or extremely noisy dataWindow of at least 4x the scrape interval
topk() inside an alertReturns a different series set on each evaluation, so the alert flaps and never satisfies for:Alert on a threshold; use topk in the notification annotation or dashboard
{__name__=~".+"}Matches every series in the databaseAlways anchor on a metric name and a job
increase(x[24h]) on a dashboardScans a full day of samples for every panel refreshUse a recording rule computed at ingest
avg of a histogram_quantileAveraging percentiles produces a meaningless number (Part 4)sum by (le) first, then one histogram_quantile

Hands-on: labs/06-promql in the companion repo has a seeded Prometheus with deliberately broken queries alongside their fixes, plus promtool unit tests for every recording rule above.

Key takeaways

  • Counter for things that only go up, gauge for things that move both ways, histogram for anything you need percentiles of.
  • Never graph a raw counter. rate() and increase() exist because counters reset.
  • rate() before sum(), always. Aggregating counters before rating them corrupts reset handling.
  • Ratio of sums, never average of ratios.
  • Empty panels are usually label-matching failures, not missing data. Check both sides separately.
  • Move expensive and frequently used expressions into recording rules with the level:metric:operations naming convention.
  • Use base units - seconds and bytes - everywhere.

Quick answers

When should I use irate instead of rate?

Only for high-resolution debugging graphs where you want to see individual spikes. irate uses just the last two samples, so it is extremely sensitive to noise and will miss activity entirely when the graph step is larger than the scrape interval. Never use it in an alerting rule.

Why does my division return no data?

Almost always vector matching. Prometheus matches series by their complete label sets, and if one side carries a label the other does not, nothing matches and you get an empty result. Run each side separately, compare the labels, and use on(), ignoring(), or group_left to reconcile them.

How long should my rate window be?

At least four times the scrape interval so a missed scrape does not create a gap. With a 15-second scrape, 1m works and 5m is the safer default. Match the window across related queries so that graphs shown side by side are comparable.

Are summaries ever the right choice over histograms?

Only when you need an accurate quantile from a single process and will never aggregate across replicas - a batch job or a CLI tool, for example. In any horizontally scaled service, summaries cannot be combined and histograms are the correct type.