Cardinality is the number of unique time series in your Prometheus database. Each unique combination of metric name and label values creates a separate time series. When a single metric with five labels has 10 possible values per label, that creates 100,000 time series from one metric alone. According to CNCF’s 2023 observability survey, 59% of teams using Prometheus report storage and query performance issues at scale, with cardinality explosion being the most common root cause.
High cardinality crashes Prometheus by exhausting memory during ingestion, slowing queries to timeouts, and ballooning storage costs. The problem is silent until it breaks your monitoring, often during an incident when you need it most.
This guide walks through finding high cardinality metrics using Prometheus’s built-in TSDB status API and PromQL queries, fixing the most common label problems at the source, and implementing relabeling rules to prevent cardinality from exploding again. Every technique includes working examples tested on production Prometheus setups handling billions of samples per day.
Prerequisites
Before starting, you need:
- Prometheus 2.x or later running in production with active scraping
- Access to the Prometheus web UI at
http://prometheus:9090 kubectlaccess if running Prometheus in Kubernetescurlandjqinstalled for API queries- Write access to Prometheus configuration files or ServiceMonitor CRDs
Step 1: Check Your Current Cardinality
Start by measuring total time series and identifying growth patterns. Prometheus exposes these metrics about itself.
Query total active time series in the Prometheus UI query box:
prometheus_tsdb_head_series
This returns the current count of active time series in memory. If this number exceeds 10 million on a single Prometheus instance with default resource limits, you are in high cardinality territory and query performance will degrade noticeably.
Check how fast new series are being created:
rate(prometheus_tsdb_head_series_created_total[5m])
If this rate is consistently above 1,000 new series per second, cardinality is growing faster than most Prometheus setups can handle sustainably.
Track series churn to see how many are being removed:
rate(prometheus_tsdb_head_series_removed_total[5m])
When creation rate far exceeds removal rate, storage and memory pressure builds continuously until Prometheus crashes or restarts.
Step 2: Find High Cardinality Metrics
The fastest way to find problem metrics is using Prometheus’s TSDB status API. This endpoint ranks metrics by series count without writing any PromQL.
Port forward to your Prometheus instance if running in Kubernetes:
kubectl port-forward -n monitoring prometheus-kube-prometheus-stack-prometheus-0 9090:9090
Query the TSDB status endpoint:
curl -s http://localhost:9090/api/v1/status/tsdb | jq '.data'
The response includes four critical rankings:
seriesCountByMetricName: Top metrics by total series countlabelValueCountByLabelName: Labels with the most unique valuesmemoryInBytesByLabelName: Labels consuming the most memoryseriesCountByLabelValuePair: Most explosive label value combinations
Focus on seriesCountByMetricName first. The output looks like this:
{
"seriesCountByMetricName": [
{"name": "http_requests_total", "value": 2847293},
{"name": "api_request_duration_seconds_bucket", "value": 1923847},
{"name": "container_cpu_usage_seconds_total", "value": 892341}
]
}
Any metric with over 100,000 series is a cardinality problem. Any metric over 1 million series is a severe problem that will crash Prometheus during high traffic.
You can also rank metrics using PromQL:
topk(20, count by (__name__)({__name__=~".+"}))
This query counts series per metric and returns the top 20 offenders. Run this every week and track whether the top metrics are growing or stable.
Step 3: Identify Label Problems
High cardinality always comes from labels. Once you know which metrics are exploding, inspect their label combinations to find the root cause.
Check cardinality of a specific metric by label:
count by (endpoint) (http_requests_total)
If endpoint has 10,000 unique values, every additional label multiplies that number. Adding user_id with 50,000 values would create 500 million series.
Find labels with the most unique values across all metrics:
curl -s http://localhost:9090/api/v1/status/tsdb | jq '.data.labelValueCountByLabelName'
The most dangerous labels typically are:
user_id,session_id,request_id— unbounded identifiers that grow with trafficpath,endpoint,url— when not normalized, every unique URL becomes a seriestrace_id,span_id— distributed tracing IDs that should never be labelspod,container_id— in Kubernetes, when pods scale or churn, these multiply series
Check how many unique values a suspicious label has:
count(count by (user_id) (http_requests_total))
If this returns more than 10,000, user_id is causing cardinality explosion and must be removed or aggregated.
Step 4: Fix Label Problems at the Source
The best fix is preventing high cardinality labels from reaching Prometheus. This requires changing how your application or exporter emits metrics.
Problem: Unbounded user IDs in labels
Bad example that creates millions of series:
http_requests = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'user_id'] # user_id is unbounded
)
http_requests.labels(method='GET', endpoint='/api/orders', user_id='user_12345').inc()
Fix by removing user_id or bucketing it into user types:
http_requests = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'user_type']
)
def get_user_type(user_id):
# Aggregate users into buckets
if is_premium(user_id):
return 'premium'
elif is_trial(user_id):
return 'trial'
else:
return 'free'
http_requests.labels(
method='GET',
endpoint='/api/orders',
user_type=get_user_type(user_id)
).inc()
This reduces cardinality from millions of unique user IDs to three user types.
Problem: Path parameters in URL labels
Bad example where every unique URL creates a series:
httpRequests := prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "http_requests_total",
Help: "Total HTTP requests",
},
[]string{"method", "path", "status"},
)
// This creates a new series for every user ID and order ID
httpRequests.WithLabelValues("GET", "/users/123/orders/456", "200").Inc()
httpRequests.WithLabelValues("GET", "/users/789/orders/012", "200").Inc()
Fix by normalizing paths to route patterns:
func normalizePath(path string) string {
// Replace numeric IDs with placeholders
re := regexp.MustCompile(`/\d+`)
return re.ReplaceAllString(path, "/{id}")
}
normalizedPath := normalizePath("/users/123/orders/456")
// Returns: "/users/{id}/orders/{id}"
httpRequests.WithLabelValues("GET", normalizedPath, "200").Inc()
This collapses millions of unique paths into dozens of route patterns.
Problem: Timestamp or UUID labels
Never use timestamps, UUIDs, or trace IDs as Prometheus labels:
# Bad: every request creates a new series
- metric: request_duration_seconds
labels:
request_id: "550e8400-e29b-41d4-a716-446655440000"
timestamp: "2026-01-15T10:30:00Z"
Fix by removing these labels entirely. Use histogram buckets to measure duration distribution instead:
request_duration = Histogram(
'request_duration_seconds',
'Request duration in seconds',
['method', 'endpoint'],
buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0]
)
request_duration.labels(method='GET', endpoint='/api/users').observe(0.34)
Histograms give you percentile queries without creating a series per request.
Step 5: Drop High Cardinality Labels Using Relabeling
If you cannot fix the source immediately, use Prometheus relabeling to drop problematic labels before ingestion.
Drop specific labels from all metrics
Add this to your Prometheus scrape config:
scrape_configs:
- job_name: 'api-service'
static_configs:
- targets: ['api:9090']
metric_relabel_configs:
# Drop high cardinality labels
- regex: '(user_id|session_id|request_id|trace_id)'
action: labeldrop
This removes those four labels from every metric scraped from the api-service job.
Replace dynamic values with placeholders
Normalize path labels inside Prometheus:
metric_relabel_configs:
# Replace numeric IDs in path label
- source_labels: [path]
regex: '(.*/)\d+(.*)'
replacement: '${1}{id}${2}'
target_label: path
This rule transforms /users/123/orders/456 into /users/{id}/orders/{id} at scrape time.
Add aggregated labels for grouping
Create a status_class label for better aggregation:
metric_relabel_configs:
# Add status class for 2xx responses
- source_labels: [status_code]
regex: '2..'
replacement: '2xx'
target_label: status_class
# Add status class for 4xx responses
- source_labels: [status_code]
regex: '4..'
replacement: '4xx'
target_label: status_class
# Add status class for 5xx responses
- source_labels: [status_code]
regex: '5..'
replacement: '5xx'
target_label: status_class
Now you can query by status_class instead of hundreds of individual status codes.
Step 6: Drop Entire Metrics
Some metrics are not worth keeping. Drop them before ingestion to save memory and storage.
Drop metrics by name pattern:
metric_relabel_configs:
# Drop debug metrics in production
- source_labels: [__name__]
regex: 'debug_.*'
action: drop
# Drop unused node exporter metrics
- source_labels: [__name__]
regex: 'node_(nf_conntrack_stat|netstat_.*6|timex_pps).*'
action: drop
Drop metrics with specific label values:
metric_relabel_configs:
# Drop metrics from non-production environments
- source_labels: [__name__, environment]
regex: '.*;(development|staging)'
action: drop
This prevents dev and staging metrics from consuming production Prometheus resources.
Step 7: Set Cardinality Limits
Prometheus allows setting hard limits to prevent cardinality from crashing the instance.
Add these limits to your Prometheus config:
global:
scrape_interval: 15s
# Limit samples per scrape to 10,000
sample_limit: 10000
# Limit labels per sample to 30
label_limit: 30
# Limit label name length to 128 characters
label_name_length_limit: 128
# Limit label value length to 512 characters
label_value_length_limit: 512
scrape_configs:
- job_name: 'high-cardinality-app'
# Override global limit for this job
sample_limit: 5000
static_configs:
- targets: ['app:9090']
When a scrape exceeds sample_limit, Prometheus rejects the entire scrape and logs an error. This protects the instance but creates a blind spot. Monitor the prometheus_target_scrapes_exceeded_sample_limit_total metric to detect when limits are hit.
Step 8: Create Recording Rules to Pre-Aggregate
Recording rules compute expensive queries in advance and store the results as new time series with lower cardinality.
Create a recording rule to aggregate per-instance metrics to per-service:
groups:
- name: cardinality_reduction
interval: 1m
rules:
# Aggregate HTTP requests per service instead of per pod
- record: service:http_requests:rate5m
expr: |
sum by (service, method, status_class) (
rate(http_requests_total[5m])
)
# Aggregate error rate per service
- record: service:http_errors:rate5m
expr: |
sum by (service) (
rate(http_requests_total{status_class="5xx"}[5m])
)
Dashboards and alerts should query service:http_requests:rate5m instead of the raw http_requests_total metric. This reduces cardinality in queries by 100x when you have hundreds of pods per service.
Step 9: Monitor Cardinality Continuously
Set up alerts to catch cardinality explosions before they crash Prometheus.
Alert when total series crosses a threshold:
groups:
- name: cardinality_alerts
rules:
- alert: HighTotalCardinality
expr: prometheus_tsdb_head_series > 8000000
for: 10m
labels:
severity: warning
annotations:
summary: "Prometheus has {{ $value }} active series"
description: "Total series count is above 8 million. Investigate high cardinality metrics."
- alert: FastCardinalityGrowth
expr: rate(prometheus_tsdb_head_series_created_total[5m]) > 1000
for: 10m
labels:
severity: warning
annotations:
summary: "Prometheus creating {{ $value }} series per second"
description: "Series creation rate is unsustainable. Check for label explosions."
Alert when a specific metric crosses a cardinality threshold:
- alert: HighMetricCardinality
expr: count by (__name__) ({__name__=~".+"}) > 100000
for: 5m
labels:
severity: warning
annotations:
summary: "Metric {{ $labels.__name__ }} has {{ $value }} series"
description: "Metric cardinality is above 100k. Review labels."
These alerts catch cardinality problems hours before Prometheus runs out of memory.
Step 10: Monitoring CubeAPM Metrics with Prometheus
CubeAPM exposes Prometheus-compatible metrics for monitoring its own performance. If you run CubeAPM as part of your observability stack, scraping these metrics helps track trace ingestion, query performance, and storage health alongside your application metrics.
Add CubeAPM to your Prometheus scrape targets:
scrape_configs:
- job_name: 'cubeapm'
static_configs:
- targets: ['cubeapm:9090']
metric_relabel_configs:
# Drop high cardinality trace IDs
- regex: 'trace_id'
action: labeldrop
CubeAPM uses OpenTelemetry natively and stores traces in ClickHouse, which keeps cardinality under control by design. Unlike Prometheus, ClickHouse handles high cardinality queries efficiently, so trace IDs and span IDs do not cause the same memory pressure. However, when exposing metrics about CubeAPM’s performance to Prometheus, dropping trace IDs from metric labels prevents Prometheus cardinality issues.
Troubleshooting Common Issues
Prometheus crashes with “out of memory” during high traffic
Symptom: Prometheus pod or process crashes with OOMKilled or out of memory errors during traffic spikes.
Cause: High cardinality metrics consume too much memory during ingestion. Each unique label combination requires memory for indexing.
Fix: Identify the top 5 metrics by series count using the TSDB status API. Drop or relabel the highest cardinality offenders. Increase memory limits only after reducing cardinality — adding memory without fixing labels just delays the crash.
Queries timeout or take over 30 seconds
Symptom: PromQL queries hang or return query processing would load too many samples errors.
Cause: Queries touching millions of series exhaust query engine limits.
Fix: Use recording rules to pre-aggregate high cardinality metrics. Query the recording rule output instead of raw metrics. Avoid queries like sum(rate(http_requests_total[5m])) without filtering by service or endpoint first.
Prometheus scrape times exceed scrape interval
Symptom: Prometheus logs show scrapes taking longer than the configured scrape interval, causing missed scrapes.
Cause: Exporter exposes too many metrics or Prometheus is overloaded.
Fix: Set sample_limit per job to reject oversized scrapes. Disable unused collectors at the exporter level. For node exporter, use --no-collector.arp, --no-collector.ipvs, and --no-collector.sockstat flags.
Relabeling rules are not applied
Symptom: High cardinality labels still appear in metrics after adding labeldrop rules.
Cause: Rules are placed in relabel_configs instead of metric_relabel_configs, or the regex pattern does not match.
Fix: Use metric_relabel_configs to modify metrics after scraping. Use relabel_configs to modify target labels before scraping. Test regex patterns with a tool like regex101.com before deploying.
Series churn is high but total series stays stable
Symptom: rate(prometheus_tsdb_head_series_created_total[5m]) and rate(prometheus_tsdb_head_series_removed_total[5m]) are both high, but prometheus_tsdb_head_series is stable.
Cause: Labels with dynamic values change frequently, creating and removing series continuously. Common with pod names in Kubernetes or short lived containers.
Fix: Drop or aggregate dynamic labels. Use recording rules to pre-aggregate at a stable level like service or namespace instead of pod.
High cardinality is a design problem, not a configuration problem. The only permanent fix is changing how metrics are instrumented at the source. Relabeling and limits buy time but do not solve the root cause.
Disclaimer: The information in this article reflects the latest details available at the time of publication and may change as technologies and products evolve. Features, pricing, and plan limits can change over time. Always verify the latest information directly with the vendor before making purchasing or deployment decisions.
Frequently Asked Questions
What is considered high cardinality in Prometheus?
High cardinality in Prometheus is when a single metric or set of labels creates over 100,000 unique time series. When total series across all metrics exceeds 5 to 10 million on one instance, query performance degrades noticeably and memory pressure builds toward crashes.
How do I check Prometheus cardinality?
Use the TSDB status API at `http://prometheus:9090/api/v1/status/tsdb` to see cardinality ranked by metric name and label. Run `prometheus_tsdb_head_series` in the Prometheus UI to check total active series. Use `topk(20, count by (__name__)({__name__=~”.+”}))` to rank metrics by series count.
Can relabeling rules reduce cardinality?
Yes, relabeling rules can drop high cardinality labels or normalize dynamic values before ingestion. Use `metric_relabel_configs` with `labeldrop` or regex replacement to remove or aggregate problematic labels. However, this does not reduce memory used during scraping, only during storage.
What causes cardinality explosions in Kubernetes?
Pod names, container IDs, and node names create cardinality explosions when used as labels. Every time a pod restarts or scales, new series are created. Aggregating metrics at the service or namespace level instead of the pod level prevents this.
Should I use histograms or counters to avoid cardinality?
Use histograms for measuring distributions like latency or response size. Histograms create a fixed number of series based on bucket count, not on observed values. Counters with high cardinality labels create one series per unique label combination and explode with traffic.
How does CubeAPM handle high cardinality differently than Prometheus?
CubeAPM stores traces and high cardinality data in ClickHouse, which is optimized for high cardinality queries. Prometheus stores all data in memory mapped files and struggles when cardinality exceeds millions of series. CubeAPM uses smart sampling to reduce stored trace volume while retaining high cardinality dimensions like trace IDs and span IDs for root cause analysis.
What is the fastest way to fix a cardinality crisis in production?
Drop the highest cardinality metric entirely using a `metric_relabel_configs` rule with `action: drop`. Identify the offending metric with the TSDB status API, add a drop rule, and reload Prometheus config. This buys time to fix the label problem at the source without restarting services.





