High metric cardinality: one label can create thousands of time series Intermediate
Estimated reading time: 6 min read
Symptoms
Section titled “Symptoms”After a deploy, Prometheus memory grows, scrapes slow down and dashboard queries take longer, while traffic is unchanged. The diff contains a seemingly harmless change: a user_id label added to the http_requests_total counter “to make filtering by customer easier”.
Cardinality is the number of distinct label value combinations of a metric; each combination is its own time series. A label with unbounded values (user ID, email, request ID, a URL containing an ID) turns the metric into an event lookup store, a job metrics were not designed for.
Reproduction: label dimensions multiply
Section titled “Reproduction: label dimensions multiply”Three route templates, two status groups, 100 fake users: 3 × 2 = 6 series; adding user_id gives 3 × 2 × 100 = 600.
Loading diagram…
The number is the ceiling of combinations that appear, not bytes of RAM. The table below shows where it starts to matter:
| Series | Example | Risk level |
|---|---|---|
| 6 | route × status | Negligible |
| 600 | add 100 users | Still small, but the model is wrong; real users number in the millions |
| 400,000 | 50 routes × 5 methods × 10 statuses × 8 regions × 20 instances | Needs a series budget and monitoring |
| 10 million | add an unbounded label | An incident; do not rely on a “so many KB per series” rule, since it depends on version and workload |
Histograms multiply further by the number of buckets (plus _count, _sum); an unbounded label on a histogram is even more expensive. instance, attached by Prometheus at scrape time, is also a multiplier by the number of replicas.
Run the combination simulation (no Prometheus needed):
npx --prefix examples ts-node examples/src/metric-cardinality/cardinality-demo.tsChange 100 to 10_000 to see the number of combinations grow. Only combinations actually recorded create series; constantly changing dynamic values create churn (old series stop, new series appear), so the cost also depends on the rate of change.
How to detect it
Section titled “How to detect it”Questions and the matching tools:
| Want to know | Use |
|---|---|
| Total active series and trend | prometheus_tsdb_head_series |
| Rate of new series creation (churn) | rate(prometheus_tsdb_head_series_created_total[5m]) |
| Which metrics have the most series | topk(10, count by (__name__) ({__name__=~".+"})) (heavy query, run with care) |
| Which labels have the most values | GET /api/v1/status/tsdb, read seriesCountByMetricName, labelValueCountByLabelName, memoryInBytesByLabelName, seriesCountByLabelValuePair |
| Deep analysis of one block | promtool tsdb analyze <db path> |
The /api/v1/status/tsdb endpoint accepts a limit parameter (default 10, max 10000) and is an operational endpoint that should not be public.
Compare before and after a deploy to find new metrics or labels; total memory alone is not enough to conclude anything, because retention, ingest and query load all affect it.
In code, review every call to .labels(...): what is the maximum set of values, and can the client control them? Also check middleware that exports metrics on its own; it often uses the raw URL if route normalization is not configured.
Immediate mitigation
Section titled “Immediate mitigation”Guardrails: let the scrape fail instead of letting Prometheus OOM
Section titled “Guardrails: let the scrape fail instead of letting Prometheus OOM”By default these limits are 0 (unlimited). When sample_limit, label_limit, label_name_length_limit or label_value_length_limit is exceeded, the whole scrape is treated as failed, so you see up == 0 instead of losing memory.
scrape_configs: - job_name: orders-api sample_limit: 20000 # max samples per scrape (after metric relabeling) label_limit: 30 # max labels per sample label_value_length_limit: 200 # bytes target_limit: 50 # max targets for the job (experimental feature)Pick the numbers from real observations of each job, plus a margin. The limits only block at the scrape boundary; labels derived from input still have to be normalized in code.
Long-term prevention
Section titled “Long-term prevention”Series budget: estimate product of label values × number of replicas for each metric family before merging, and alert on prometheus_tsdb_head_series together with churn.
Review labels with a table:
| Label | Max number of values | Bounded? | Owner |
|---|---|---|---|
route |
number of route templates | Yes (templates; unknown routes become unmatched) |
API team |
status_class |
5 | Yes | API team |
tier |
3 (free, paid, enterprise) |
Yes | Billing team |
user_id |
millions | No, forbidden |
Alternatives when you need to look up individuals:
- Exemplars (a metric pointing to a
trace_id) plus logs or traces with retention and access control; see logs, metrics, traces. - Normalize labels derived from input into a finite set; test that unknown input falls into a fallback group.
- Native histograms reduce the series caused by buckets compared to classic histograms.
- For high-dimensional analysis, use an event store or analytics system, not metrics.
Checklist
Section titled “Checklist”- Every new label has a maximum number of values, is bounded and has an owner (the label review table above).
- Do not use
user_id,request_id, email or a raw path containing IDs as a label; use route templates, with unknown routes asunmatched. - Estimate the series budget (
product of label values × number of replicas, times buckets for histograms) before merging. - Set
sample_limitandlabel_limitper job based on real observations plus a margin. - Alert on
prometheus_tsdb_head_seriesand churnrate(prometheus_tsdb_head_series_created_total[5m]). - After a deploy, compare
/api/v1/status/tsdbbefore and after to find new metrics or labels. - When Prometheus is overloaded: stop the source first (roll back or drop the label), use
metric_relabel_configsto relieve pressure, never delete TSDB data by hand.
Summary
Section titled “Summary”Self-check: (1) Why does adding 100 user IDs turn 6 combinations into up to 600? (2) What happens to the scrape when sample_limit is exceeded? (3) To look up one specific request, do you use metrics, logs or traces?