A Prometheus time series is identified by its metric name plus every label value. http_requests_total{method="GET", status="200"} and http_requests_total{method="GET", status="500"} are two different series, stored and indexed separately, each with its own memory footprint in the head block.
Cardinality is therefore the product of every label’s distinct values:
methods (5) × statuses (8) × handlers (40) × pods (24) = 38,400 seriesMultiply in one label whose values are unbounded — a user ID, a session ID, an order ID, a full request path containing IDs, an error message string — and the product is no longer a number anyone chose:
… × path (one per order ever seen) = unboundedWhat it costs
Active series live in memory. A few kilobytes each sounds negligible until the count moves by an order of magnitude, and the failure is not graceful:
- memory grows with the series count until the process is killed
- on restart, the write-ahead log must be replayed, and replay time grows with the same number — so a large head block turns each restart into minutes of unavailability
- during replay nothing is scraped, so every restart cuts a hole in every dashboard and every alert
That last point is what makes this worse than an ordinary capacity problem: the monitoring system’s recovery is itself an outage of monitoring.
The rule
A label value must come from a set you can name. Method, status class, route template, service, environment, region — all bounded, all enumerable in advance. Anything derived from user input or from an identifier is not a label; it belongs in a log or a trace, where the storage model expects high cardinality.
Route templates are the common trap: /v1/orders/:id is one series, /v1/orders/1a77c0e4/items is one series per order — see what that does to a monitoring stack.
Finding and bounding it
topk(5, count by (__name__)({__name__=~".+"})) -- which metric
count(count by (path) (http_requests_total)) -- which label, and how badpromtool tsdb analyze /prometheus # highest-cardinality metric names and labels, offlineThe structural control is a per-scrape-pool limit, and it matters more than any amount of discipline:
scrape_configs:
- job_name: kubernetes-pods
sample_limit: 20000
label_limit: 30
label_value_length_limit: 200A target that exceeds sample_limit fails its own scrape and is marked down. Without it, one badly-labelled service degrades the monitoring of everything else — the limit’s real purpose is to make the blast radius the target rather than the cluster.