Systeric / Docs
Open App →

Cardinality

Cardinality is the concept behind more “why did our observability fall over” incidents than any other. It’s also invisible until it hurts, which is exactly why it belongs in this group. If you internalise one metrics concept, make it this one.

What it is#

Cardinality = the number of unique time series you are storing.

A time series is a metric name plus every label value on it. Change any single label value and it becomes a different series, stored, indexed, and tracked separately.

http.server.duration{service="nest-prod", route="/users/:id", status="200", le="250ms"}   ← series #1
http.server.duration{service="nest-prod", route="/users/:id", status="200", le="500ms"}   ← series #2  (different le!)
http.server.duration{service="nest-prod", route="/reviews",   status="200", le="250ms"}   ← series #3

Each of those is a separate bucket of numbers-over-time the database maintains forever (until retention drops it).

Why it’s so impactful: it multiplies#

Cardinality isn’t additive, it’s combinatorial. A metric’s series count is roughly the product of its label cardinalities. A real example from our stack, signoz_latency:

LabelDistinct values
operation774
le (histogram bucket)18
service.name5
status.code2
span.kind3

Upper bound = 774 × 18 × 5 × 2 × 3 ≈ 418,000 possible series. Add one more label with 100 values and you don’t add 100, you multiply the whole thing by up to 100. That’s the trap: labels feel free when you add them, and cost is hiding in a product you never multiplied out.

The three costs (why one number hurts everywhere)#

1. Query cost, the one you feel. To answer “p99 for service X over 7 days,” the database reads every datapoint of every matching series and aggregates. More series = more rows. We measured a single 7-day latency query reading 15.5 million rows. Cardinality is the direct input to how much a query has to chew through. (This is the “why is it doing so much work?” smell from how to debug.)

2. Memory & storage. The system keeps per-series metadata, a fingerprint, an index entry, cache slots. The collector holds a live map of every active series in RAM. 12k series is fine; 400k blows the cache, bloats the series table, and can OOM the collector or the database.

3. Ingestion & merges. Each series is written and continuously compacted. High cardinality means more parts and more background merge CPU, the quiet load that starves everything else on the box.

The rule that prevents 90% of disasters#

Before adding a label, ask one question: can its value space grow without bound?

Safe (bounded)Dangerous (unbounded, grows with traffic)
status_code, http_methoduser_id, session_id, request_id
service, region, envtrace_id (literally infinite)
templated route /users/:idraw URL /users/12345 (new value per user)
feature flag on/offemail, ip_address, full SQL text, timestamps

One unbounded label = unbounded cardinality = eventual meltdown. This is why “template your routes” is the classic advice: it converts an unbounded dimension (/users/12345, /users/98, …) into one bounded value (/users/:id).

The counter-intuitive part: cardinality ≠ traffic#

This is what trips everyone up. It is not how many requests you get, it’s how many distinct label combinations:

  • 1,000,000 requests to /health → 1 series. Cheap, at any volume.
  • 1,000 requests, each to a unique /users/{id} → 1,000 series. Expensive.

High traffic on low cardinality is easy. Low traffic on high cardinality is what kills observability systems. You optimise for diversity, not volume.

Histograms are cardinality amplifiers#

A histogram (like latency) is not one series, it’s one series per bucket. Those 18 le buckets mean everything else is ×18. That’s why latency metrics are almost always your most expensive, and why percentile panels are the first to fall over.

How to audit it yourself#

The one query that shows your worst offenders (run in ClickHouse, see finding slow queries):

SELECT metric_name, uniqExact(fingerprint) AS series
FROM signoz_metrics.time_series_v4
GROUP BY metric_name
ORDER BY series DESC
LIMIT 20;

And to find which label is exploding a given metric, count distinct values per label key. A label with thousands of values on a metric you didn’t expect is your culprit.

The mental model to keep#

A metric costs roughly (product of its label cardinalities) × (data points over time). Before adding a label, ask “how many distinct values can this ever have?” If the answer is “it grows with users / requests / time,” it does not belong in a metric label, it belongs in a trace or a log, which are built for high-cardinality detail.

That last sentence is the whole reason we say metric is metric, trace is trace (see metrics vs traces vs logs): metrics are for bounded, aggregatable dimensions; traces and logs are where unbounded, per-request detail lives.