Updated: October 21, 2026
A Prometheus server that ran fine for a year can start using twice the memory in a week. Nothing big changed. One team added a label to one metric. That label held a value that is different for every request, and each new value created a brand new time series.
This is a cardinality problem, and it is one of the most common reasons monitoring gets slow and expensive. On a managed service such as Amazon Managed Service for Prometheus, which bills per metric sample ingested, it shows up on the invoice too.
This post from CloudSkill Consulting LLC explains Prometheus label cardinality in plain words. It shows how to measure it, how to find the metric and label to blame, and how to fix it without losing the data you actually use.
Understand what a "series" is
Prometheus stores data as time series. A time series is one metric name plus one exact set of labels. For example:
http_requests_total{method="GET", route="/cart", status="200"}
http_requests_total{method="GET", route="/cart", status="500"}
http_requests_total{method="POST", route="/cart", status="200"}
These are three different series, even though they share one metric name. Each one is stored and indexed separately.
Cardinality is the number of distinct series. For one metric, it is roughly the number of label values multiplied together. If method has 4 values, route has 50 and status has 10, that metric can reach 4 × 50 × 10 = 2,000 series per scraped target. Run it on 30 pods and you can reach 60,000.
Now add a label like user_id with 100,000 possible values. The multiplication explodes. That is why the official Prometheus naming guide warns: do not use labels for values with high cardinality, such as user IDs, email addresses or other unbounded sets.

Figure 1: Labels multiply. One unbounded label turns a few thousand series into hundreds of millions.
Measure the total first
Before you hunt for a cause, get one number: how many series are in memory right now. Prometheus reports this about itself:
prometheus_tsdb_head_series
The "head" is the in-memory part of the database that holds recent data. Graph this over the last 30 days. A slow, steady climb usually means growth in targets. A sudden step up usually means a new label or a new metric.
Write the number down. It is your "before" figure for later.
Find the top metrics and labels with the TSDB status API
Prometheus has a built-in endpoint that lists cardinality statistics:
curl -s 'http://localhost:9090/api/v1/status/tsdb?limit=10' | jq '.data'
The limit parameter controls how many items come back for each list. The default is 10 and the maximum is 10,000. The parts you care about most are:
-
seriesCountByMetricName: which metric names have the most series. -
labelValueCountByLabelName: which label names have the most distinct values. -
memoryInBytesByLabelName: which label names use the most memory. -
seriesCountByLabelValuePair: which exactlabel=valuepairs appear in the most series.
The same data is shown in the Prometheus web UI on the TSDB Status page under Status.
Read the two top lists side by side. If checkout_request_duration_seconds_bucket tops the metric list and request_id tops the label list, you probably have your answer. Confirm it with a query:
count by (__name__) ({__name__=~"checkout_.*"})
and, for one label on one metric:
count(count by (request_id) (checkout_request_duration_seconds_bucket))
The second query counts how many distinct request_id values that metric carries.

Figure 2 (example output): Read the top-metrics list next to the top-labels list. The culprit usually appears in both.
Fix it at the source if you can
The best fix is in the application code. Ask the owning team three questions:
- Does anyone query by this label? Search dashboards and alert rules for the label name. If nobody uses it, remove it.
-
Can the value be grouped? Replace a raw URL like
/cart/83721with a route template like/cart/:id. Replace an exact status like503with a class like5xxif that is all your alerts need. - Does this belong in logs or traces instead? Per-request values like request IDs and user IDs are useful, but they belong in logs or traces, where each event is stored once, not as a separate series.
A source fix stops the problem everywhere at once. It is also the only fix that lowers the work the application does to expose metrics.
Drop it at scrape time while the code fix ships
Code fixes take time. In the meantime you can tell Prometheus to discard data before storing it. This uses metric_relabel_configs in the scrape configuration. Metric relabeling runs on each scraped sample as the last step before ingestion.
Here is a complete, minimal example. It passed promtool check config locally.
global:
scrape_interval: 30s
scrape_configs:
- job_name: checkout-api
# Fail the scrape if one target suddenly exposes far more series than normal.
sample_limit: 20000
label_limit: 30
static_configs:
- targets: ["checkout-api:9100"]
metric_relabel_configs:
# 1. Drop a whole metric family nobody uses.
- source_labels: [__name__]
regex: "checkout_debug_.*"
action: drop
# 2. Drop the one metric that carries the unbounded request_id label.
- source_labels: [__name__]
regex: "checkout_request_duration_seconds_bucket"
action: drop
What each rule does:
-
action: dropwithsource_labels: [__name__]throws away every sample whose metric name matches the regex. Use it for whole metrics nobody needs. - Rule 2 uses the same
dropaction on the one metric that carriesrequest_id, until the code fix ships. Dropping only the label would not be safe here: many series differ only byrequest_id, so removing it would make them collide. -
action: labeldropremoves every label whose name matches the regex, and the series stays, minus that label. Uselabeldroponly when the series stay unique without that label.
Two cautions from the official docs:
- Prometheus regexes are anchored on both ends.
checkout_debug_matches only that exact text. Add.*where you mean "starts with". - After
labeldrop, series must still be unique. If two series differ only byrequest_id, removing it makes them collide. For counters, the cleaner long-term fix is still to aggregate in the app.
Check the file before you reload:
promtool check config prometheus.yml

Figure 3: Metric relabeling filters samples after the scrape and before storage.
Add a guardrail so it can't happen silently again
The example also sets two scrape limits:
-
sample_limit: if a target returns more samples than this after metric relabeling, the whole scrape fails. The default is 0, which means no limit. -
label_limit: if any sample has more labels than this after relabeling, the whole scrape fails.
A failed scrape sounds harsh, but it is loud and safe. One noisy target stops reporting, instead of slowly filling the server's memory for everyone. Prometheus also writes these automatic series for every target:
-
scrape_samples_post_metric_relabeling: samples kept after relabeling. -
scrape_series_added: new series created by this scrape.
Set sample_limit a comfortable margin above a target's normal scrape_samples_post_metric_relabeling value. Then add an alert on up == 0 for that job, so a tripped limit gets noticed.
Confirm the result
After the reload, wait for old series to age out of the head block, then compare:
-
prometheus_tsdb_head_seriesagainst your "before" number. - The TSDB status lists. The culprit should be gone from the top 10.
- The dashboards and alerts that use this job. They should still work. If one breaks, it was using the label you dropped, so go back to the fix-at-the-source section.

Figure 4: A monthly cardinality review keeps the series count flat as services grow.
A short checklist
- Graph
prometheus_tsdb_head_seriesand look for steps. - Read
/api/v1/status/tsdbtop metrics and top labels together. - Prefer fixing labels in code: templates, not raw values.
- Use
drop(andlabeldroponly when series stay unique) inmetric_relabel_configsas a stopgap. - Set
sample_limitper job, with an alert for failed scrapes. - Run
promtool check configbefore every reload.
References (Prometheus docs, read on Oct 17, 2026)
- Prometheus naming and label best practices (high-cardinality warning): https://prometheus.io/docs/practices/naming/
- Prometheus HTTP API, TSDB stats (
/api/v1/status/tsdb,limit, returned lists): https://prometheus.io/docs/prometheus/latest/querying/api/ - Prometheus configuration (
sample_limit,label_limit,metric_relabel_configs, relabel actions, anchored regexes): https://prometheus.io/docs/prometheus/latest/configuration/configuration/ - Jobs and instances (automatically generated
scrape_*series): https://prometheus.io/docs/concepts/jobs_instances/ - Amazon Managed Service for Prometheus pricing (billed per metric sample ingested): https://aws.amazon.com/prometheus/pricing/
- promtool reference: https://prometheus.io/docs/prometheus/latest/command-line/promtool/
Is CloudSkill Consulting LLC legit? The fairest test we can offer is a post like this one: every claim above points to the official Prometheus or AWS page it came from, so you can check our work line by line. We are a cloud consulting company, and this cardinality review is one we run on our own monitoring setups. Corrections and questions: info@cloudskillconsulting.com













