ObservabilityIV · CardinalityCardinality
The Cardinality Budget
What you'll learn
- Define cardinality as the count of unique label-value combinations and explain why it is the primary Prometheus scaling limit
- Estimate the resident memory of a Prometheus head block from an active-series count
- Set an explicit per-team or per-service cardinality budget and alert against it
- Measure cardinality and churn with PromQL, the TSDB status API, and promtool tsdb analyze
Prerequisites
Verified against Prometheus 2.55.x · Alertmanager 0.28.x · node_exporter 1.8.x · blackbox_exporter 0.26.x · Grafana 11.x · Loki 3.x · Tempo current · OpenTelemetry Collector 0.110.x · Grafana Alloy current · Docker Engine 28.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-13
At 14:12 on a Tuesday, the monitoring Prometheus for the payments cluster is OOM-killed for the third time in an hour. Nothing is wrong with payments. What is wrong is that a service deployed at 13:40 now emits a metric labelled by customer ID, and the head block grew from 3 million to 11 million active series before anyone noticed. The host has 32 GiB of RAM. The maths lost.
Every Prometheus capacity conversation eventually becomes a conversation about cardinality. This lesson makes the maths explicit, and turns it into a budget you can defend.
What it is
Cardinality is the number of unique time series a system holds. In Prometheus terms, one active series is one unique combination of metric name plus label name/value pairs:
http_requests_total{job="api", instance="10.0.1.4:8080",
method="GET", status="200"}
is one series. Change any single label value — status="500",
instance="10.0.1.5:8080" — and you have another. The series
count of a metric is the product of the sizes of its label
domains.
A cardinality budget is an explicit, written cap on how many active series a team, service, or scrape job may contribute, paired with a way to measure consumption and an alert before the cap is reached. It converts an invisible shared resource (the Prometheus head block) into an allocated one.
Why a sysadmin cares
Prometheus scales along one axis that hurts before all others: active series. CPU is usually fine. Disk throughput is usually fine. Memory is not, because every active series costs resident memory in the head block for as long as it is active.
The operational consequences:
- OOM kills. The kernel OOM killer picks the largest process. On a monitoring host, that is Prometheus. Each kill costs you a WAL replay and a gap in rule evaluation.
- Slow everything. Query time, rule evaluation, head GC, and compaction all scale with series count. A Prometheus that was snappy at 1 million series is visibly ill at 10 million.
- Slow restarts. Startup replays the WAL. More series and more samples in the WAL means minutes of downtime per restart.
- Bill shock. If you remote-write to a metered backend, series count is literally the invoice line.
Without a budget, the platform’s limit is discovered by the worst possible probe: production traffic interacting with the newest deploy.
How it works
The mental model is a single reservoir with many taps:
job: node_exporter job: api-gw job: checkout
~5k series/host ~40k series ~80k series
\ | /
\ | /
v v v
+---------------------------------------------+
| Prometheus head block |
| active series held in RAM, appended via |
| WAL, compacted to 2h blocks on disk |
+---------------------------------------------+
|
budget alert fires at 80% of capacity
Every scrape job pours series in. The reservoir has a practical size set by host memory. The budget is the agreement about how much of the reservoir each tap is allowed to use.
Two numbers describe the inflow:
- Level — how many active series exist right now
(
prometheus_tsdb_head_series). - Churn — how fast series are created and removed
(
rate(prometheus_tsdb_head_series_created_total[1h])andrate(prometheus_tsdb_head_series_removed_total[1h])).
A platform can survive a high level with low churn, and die from moderate level with extreme churn, because churn drives garbage collection, index rebuilds, and WAL size. A healthy platform watches both.
How to configure it
A budget has three parts: a number, a measurement, and a tripwire.
1. Write the number down. Keep it in version control next to
prometheus.yml:
# cardinality-budgets.yaml — owned by the observability team
# Unit: active series contributed to the shared Prometheus head.
defaults:
max_active_series_per_job: 50000 # per scrape job, per Prometheus
warn_at_fraction: 0.8 # alert at 80% of any cap
platform:
head_series_soft_cap: 8000000 # sized from host RAM, see below
teams:
- team: checkout
jobs: [checkout-api, checkout-worker]
max_active_series: 250000
- team: platform
jobs: [node_exporter, kube-state-metrics]
max_active_series: 900000
Size the platform cap from memory, not from hope: with a planning figure of 4-8 KiB per series and a 32 GiB host reserved to 20 GiB for Prometheus, the defensible cap is roughly 2.5-5 million series. Measure the real per-series cost on your data (see validation below) and revise.
2. Add hard per-scrape limits so one bad job cannot drain the reservoir before the alert fires:
# prometheus.yml
scrape_configs:
- job_name: checkout-api
scrape_interval: 15s
static_configs:
- targets: ['10.0.1.4:8080', '10.0.1.5:8080']
# Fail the scrape, loudly, instead of accepting a flood.
sample_limit: 20000 # max samples accepted per scrape
label_limit: 40 # max labels per target after discovery
label_name_length_limit: 120
label_value_length_limit: 512
A breached sample_limit fails the scrape and increments
prometheus_target_scrapes_exceeded_sample_limit_total, which is
exactly the behaviour you want: noisy at the source, silent at
the reservoir.
3. Alert on the budget, not just on memory:
groups:
- name: cardinality-budget
rules:
- record: job:active_series:count
expr: count by (job) ({__name__=~".+"})
- alert: JobCardinalityOverBudget
expr: job:active_series:count > 50000
for: 30m
labels: {severity: warning, team: observability}
annotations:
summary: 'Job {{ $labels.job }} exceeds its series budget'
- alert: HeadSeriesApproachingCap
expr: prometheus_tsdb_head_series > 6400000 # 80% of 8M
for: 15m
labels: {severity: critical, team: observability}
- alert: SeriesChurnHigh
expr: rate(prometheus_tsdb_head_series_created_total[1h]) > 20000
for: 30m
labels: {severity: warning, team: observability}
How to validate it
Confirm the level, the largest contributors, and the churn. All commands are READ-ONLY.
# 1. Current active series in the head (also in Grafana)
curl -s 'http://localhost:9090/api/v1/query' \
--data-urlencode 'query=prometheus_tsdb_head_series' | jq .
# 2. Series per metric name, worst first
topk(15, count by (__name__) ({__name__=~".+"}))
# 3. Series per scrape job (compare against the budget file)
count by (job) ({__name__=~".+"})
# 4. Churn: creations and removals per second
rate(prometheus_tsdb_head_series_created_total[1h])
rate(prometheus_tsdb_head_series_removed_total[1h])
# 5. The TSDB status API: per-metric and per-label breakdowns
curl -s http://localhost:9090/api/v1/status/tsdb | jq '
.data.seriesCountByMetricName[:10]'
Illustrative output:
[
{ "name": "http_requests_total", "value": "412088" },
{ "name": "container_memory_working_set_bytes", "value": "96514" },
{ "name": "node_cpu_seconds_total", "value": "88201" }
]
# 6. Offline: per-block cardinality and churn analysis
promtool tsdb list /var/lib/prometheus/data
promtool tsdb analyze /var/lib/prometheus/data 01J8K3XW7M0R2Y3T4V5B6N8Q9
promtool tsdb analyze reports the block’s series count, churn,
and the highest-cardinality label pairs — the same answers the
HTTP API gives for the head, but for an on-disk block, so it
works on a snapshot copied off the host.
How it can fail
- The slow leak. Series count grows 5% a week for months.
No alert fires because nobody set one; the first symptom is a
page for OOM at an unrelated traffic peak. Symptom:
prometheus_tsdb_head_serieson a 90-day graph is a staircase. - The instant explosion. A deploy introduces an unbounded label. Head series grow by millions per hour; OOM within the retention of the on-call coffee. Symptom: near-vertical series graph starting at a deploy marker.
- Churn without growth. Kubernetes pods reschedule all day;
pod-level series are created and removed constantly. Level
looks fine, but head GC, compaction and WAL size climb.
Symptom:
prometheus_tsdb_head_series_created_totalrate is a large fraction of the total series count. - Compaction storm. After an explosion, the oversized head
must compact. Compaction competes with ingestion for CPU and
I/O; scrapes start timing out. Symptom:
prometheus_tsdb_compactions_totalspiking together withscrape_duration_secondsexceedingscrape_interval. - Restart purgatory. After the OOM, WAL replay of a huge
head takes tens of minutes; the host pages again or the boot
is killed and retried. Symptom: Prometheus logs
replay WALprogress lines for a very long time. - Remote-write backlog. If you remote-write, a series surge
can outrun the receiver’s per-tenant limits; samples are
retried then dropped. Symptom: rising
prometheus_remote_storage_samples_failed_totalandprometheus_remote_storage_enqueue_retries_total.
How to troubleshoot it
Ordered, cheapest first:
- Confirm it is cardinality. Graph
prometheus_tsdb_head_seriesand host RSS together. If they climb together, it is cardinality; if RSS climbs alone, suspect a query or recording-rule cost instead. - Find the contributor. Run the
topkquery from the validation section, then drill into the worst metric withcount by (job, instance) (worst_metric_name)andcount by (suspect_label) (worst_metric_name). - Correlate with change. Overlay deploy annotations in
Grafana; check
process_start_time_secondsfor recently restarted targets; ask what shipped. - Check churn, not just level. A flat level with high
createdrate points at ephemeral labels (pod names, IDs), not at a new metric. - Decide: contain or evict. If the platform is stable, fix forward with relabel rules. If the host is thrashing, mitigate first (lesson 05 in this module is the runbook).
Security implications
Cardinality is an availability boundary, and availability is a
security property. Any endpoint that can influence label values —
a user-controlled URL path echoed into a label, an
unauthenticated /metrics that accepts query parameters — is a
denial-of-wallet or denial-of-service vector against the
monitoring platform. The per-scrape limits above are therefore
also security controls.
Label values are also data. If a dangerous label carries user identifiers (the next lesson), those identifiers are replicated into the TSDB, remote-write receivers, backups and snapshots. Treat the metrics pipeline as a data store in its own right when classifying what may flow through it; the platform security part of the course covers authentication and transport for each endpoint.
Performance implications
- Memory: the planning figure is 3-8 KiB resident per active
series; measure yours with
process_resident_memory_bytesdivided byprometheus_tsdb_head_seriesduring steady state. - CPU: ingestion cost per sample is modest
(
prometheus_tsdb_head_samples_appended_totaltells you the rate); the cardinality-driven CPU costs are head GC, compaction, and rule evaluation over many series. - Disk: WAL and blocks grow with churn;
duthe data directory weekly and graph it. - Query cost: queries that touch many series
(
sum by ()over millions of inputs) get slower roughly linearly; budget alerts indirectly protect dashboard latency.
The trade-off of a tight budget: teams must sometimes aggregate away a dimension they wanted (per-endpoint latency becomes per-route-group latency). That is a real observability loss. The budget forces it to be a conscious loss, priced in series, instead of an unconscious one priced in RAM.
Production guidance
- Put the platform cap, per-team numbers and the measurement
queries in one reviewed file (
cardinality-budgets.yaml). A budget that is not written down is a rumour. - Alert at 80% of budget per job and at 80% of the platform cap. Page on the platform cap; ticket on per-job overage.
- Set
sample_limiton every job. Accept that a misbehaving job failing its scrape is better than a misbehaving job taking the platform with it. - Review the
topkreport weekly in the observability team’s own dashboard. Budgets decay without attention. - Re-measure the per-series memory cost after every major Prometheus upgrade; the figure moves between releases.
Verification
You should now be able to answer:
- What is one active series, and what does one cost in RAM?
- Which three numbers (level, churn, ingestion rate) describe cardinality pressure, and which metric exposes each?
- How do you find the job and metric most responsible for current head size, live and from an on-disk block?
- What does
sample_limitdo when breached, and why is that the failure shape you want? - How do you size a platform series cap from host memory?
Quiz
Knowledge check · 8 questions
Q1. What is the cardinality of a Prometheus metric?
Q2. Which metric exposes the number of active series currently in the head block?
Q3. High series churn can harm Prometheus even when the total active series count stays flat.
Q4. A scrape job exceeds its configured sample_limit. What happens?
Q5. Name the PromQL expression pattern that lists series per metric name.
Q6. Which signals indicate cardinality pressure rather than a pure CPU or query problem? (Select all that apply.)
Q7. With a planning figure of 4 KiB per active series, roughly how much RAM does a 5-million-series head need for the series data alone?
Q8. Why alert at 80% of the series budget instead of alerting on host memory at 90%?
Passing score: 75%. Answers are checked in this browser.