- Series cardinality is the number of distinct series identities: in InfluxDB a measurement plus tag set, in Prometheus a metric name plus label set. It multiplies across tags.
- Our editorial view: identity attributes (site, asset, point) belong in tags or the key, and per-message values (message IDs, request IDs, raw payload hashes) do not, as the InfluxDB and Prometheus guidance on unique IDs also says; slowly changing attributes (firmware, setpoint profile) create churn.
- The cost is engine-specific. The InfluxDB v1 and v2 documentation describe high series cardinality as a primary driver of high memory use; the InfluxDB 3 documentation states (a vendor claim, not checked here) that its engine supports infinite tag value and series cardinality; Prometheus guidance is, as a general guideline, to keep the cardinality of metrics below 10.
- In TimescaleDB, cardinality shows up in the choice of
segmentbycolumn and in index size, not in a separate series index; QuestDB offers SYMBOL for repeated strings and VARCHAR or UUID for unique ones. - In SQL, keep a series dictionary table with a unique key on the identity columns and store readings against an integer series ID; descriptive attributes become updatable columns.
What cardinality means in a time-series database
A series is the stream of values that share one identity. In InfluxDB the identity is the measurement (table) and tag set; the v2 glossary defines series cardinality as the number of unique measurement, tag set and field key combinations. In Prometheus every time series is identified by its metric name and its labels. Relational designs often have the same idea under another name: a point_id or device_id column.
Cardinality multiplies. Three sites, twenty air handling units each and four points per unit is 240 series. Add a tag with two firmware versions and up to 480 combinations become possible; add a tag that is unique per message and every reading becomes its own series. This article is for engineers designing tags, labels or keys for building and IoT telemetry; for table shape (narrow versus wide) see narrow vs wide tables.
Where high cardinality comes from in IoT and BMS data
Per-message identifiers are the usual accident: a gateway sends a UUID for de-duplication and the ingestion service maps every JSON key to a tag. Handle de-duplication with the series key and timestamp instead (see handling duplicate telemetry events).
| Attribute | Example | Behaviour | Where it belongs |
|---|---|---|---|
| Identity | site, asset tag, point name | Bounded by the equipment register | Tag, label or key column |
| Slowly changing | firmware version, setpoint profile, tenant | Bounded but changes over time; each change starts new series | Dictionary or asset table column, or a separate event table |
| Per message | MQTT message ID, gateway sequence number, Kafka offset | Unique per reading; unbounded | Field or ordinary column, or not stored at all |
| Free text | alarm text, fault description | Unbounded | Field, column or a separate event table |
| Derived from values | rounded temperature band as a tag | Mixes data with identity | Compute at query time instead |
Measure cardinality and churn in SQL
Before choosing tags, load a sample of real messages into a staging table and count. The script below models a building portfolio: 3 sites, 20 AHUs per site and 4 points per AHU, one reading a minute for two hours, with half the AHUs receiving a firmware update at 01:00 and every message carrying a UUID.
SET TIME ZONE 'UTC';
CREATE TABLE telemetry_raw (
ts timestamptz NOT NULL,
site text NOT NULL,
asset text NOT NULL,
point text NOT NULL,
firmware text NOT NULL,
msg_id uuid NOT NULL,
value double precision NOT NULL
);
-- 3 sites x 20 AHUs x 4 points, one reading a minute for 2 hours;
-- firmware changes on half the AHUs at 01:00
INSERT INTO telemetry_raw
SELECT t,
'site-' || s,
'AHU-' || lpad(a::text, 2, '0'),
p,
CASE WHEN a % 2 = 0 AND t >= '2026-10-27 01:00' THEN 'v2.4.1' ELSE 'v2.3.9' END,
md5(s || '-' || a || '-' || p || '-' || t)::uuid,
random() * 30
FROM generate_series(1, 3) AS s,
generate_series(1, 20) AS a,
unnest(ARRAY['supply_air_temp', 'return_air_temp', 'fan_speed', 'zone_co2']) AS p,
generate_series(timestamptz '2026-10-27 00:00', timestamptz '2026-10-27 01:59', interval '1 minute') AS t;
SELECT count(*) AS rows FROM telemetry_raw;
-- Distinct values per candidate tag
SELECT count(DISTINCT site) AS site, count(DISTINCT asset) AS asset, count(DISTINCT point) AS point,
count(DISTINCT firmware) AS firmware, count(DISTINCT msg_id) AS msg_id
FROM telemetry_raw;
-- Series cardinality = distinct tag combinations, for three candidate tag sets
SELECT 'site, asset, point' AS tag_set, count(*) AS series
FROM (SELECT DISTINCT site, asset, point FROM telemetry_raw) s
UNION ALL
SELECT 'site, asset, point, firmware', count(*)
FROM (SELECT DISTINCT site, asset, point, firmware FROM telemetry_raw) s
UNION ALL
SELECT 'site, asset, point, msg_id', count(*)
FROM (SELECT DISTINCT site, asset, point, msg_id FROM telemetry_raw) s;
-- Churn: new series first seen per hour when firmware is a tag
SELECT date_trunc('hour', first_seen) AS hour, count(*) AS new_series
FROM (SELECT site, asset, point, firmware, min(ts) AS first_seen
FROM telemetry_raw GROUP BY 1, 2, 3, 4) s
GROUP BY 1 ORDER BY 1; rows
-------
28800
site | asset | point | firmware | msg_id
------+-------+-------+----------+--------
3 | 20 | 4 | 2 | 28800
tag_set | series
------------------------------+--------
site, asset, point | 240
site, asset, point, firmware | 360
site, asset, point, msg_id | 28800
hour | new_series
------------------------+------------
2026-10-27 00:00:00+00 | 240
2026-10-27 01:00:00+00 | 120Reading the numbers
The identity tags give 240 series, exactly the number of physical points. Adding firmware gives 360, not 480: the product of distinct values is only an upper bound, and the real figure depends on which combinations occur. The firmware update created 120 new series in one hour while the old 120 stopped receiving data; in a series-indexed engine that is churn, and it repeats with every roll-out. With the message ID as a tag there is one series per reading, and the count grows with time for as long as data arrives.
Run the same queries on a day of real gateway output, and repeat the churn query by day. Tags whose distinct count grows with time rather than with the asset register are the ones to move out of the identity.
What high cardinality costs, engine by engine
InfluxDB 1.x and 2.x (TSM storage engine). The v2 documentation explains that InfluxDB indexes measurements and tags; each unique set of indexed elements forms a series key. Both the v2 page on resolving high cardinality and the v1 schema design page name high series cardinality as a primary driver of high memory usage and describe tags with unique IDs, hashes and random strings as the typical cause. The v1 FAQ adds that InfluxDB maintains an in-memory index of every series, that RAM use grows with the number of unique series, and that high series cardinality can lead to the operating system killing the process with an out-of-memory error. How much this applies depends on the index type and version you run, which was not verified here. The v2 documentation offers SHOW SERIES CARDINALITY and influxdb.cardinality() to measure it.
InfluxDB 3. The schema design pages for InfluxDB 3 Core and Cloud Dedicated state that the storage engine supports infinite tag value and series cardinality and that tag value cardinality does not affect overall performance, unlike TSM-based versions. That is the vendor's statement, not something reproduced here. It does not make tag design irrelevant: the primary key of a row is its timestamp plus tag set, so more tags mean a more complex key that can affect sorting; the documentation refers to a maximum number of columns per table; and column order is fixed on first write. Our inference from the primary-key definition: a unique ID in a tag gives every write its own key, so two writes of the same reading are kept as two rows instead of one; check the duplicate-point behaviour documented for your edition.
Prometheus. Every unique combination of label pairs is a new time series. The naming guidance says not to use labels for high-cardinality dimensions such as user IDs or email addresses; the instrumentation guidance suggests, as a general guideline, keeping the cardinality of metrics below 10, limiting metrics that exceed that to a handful across the whole system, and investigating alternatives for anything over 100. In our judgement, building telemetry with thousands of points per site is therefore usually a poor fit for Prometheus labels (see InfluxDB vs Prometheus).
TimescaleDB and plain PostgreSQL. There is no per-series index to exhaust; a series is just a value of point_id or of the tag columns. Cardinality instead affects the columnstore: Tiger Data advises choosing segmentby from the column you filter by and states that lower-cardinality segmentby columns give better compression. Our reading of why (not stated in the page we checked) is that each segment then holds more rows to compress together. It also affects B-tree index size and the cost of text tags repeated on every row, which the dictionary table below avoids.
QuestDB. The SYMBOL type stores repeated strings as integers with a shared dictionary and suits identity tags. The documentation recommends VARCHAR (or the UUID type) for unique or very high cardinality values, and the NOCACHE option for symbol columns with 10 million or more distinct values, trading lookup speed for memory.
| Engine | What is a series | Effect of high cardinality, per the docs | Main lever |
|---|---|---|---|
| InfluxDB 1.x / 2.x | Measurement + tag set (+ field key) | Memory use grows with series count | Move unique values from tags to fields |
| InfluxDB 3 | Table + tag set; primary key = time + tag set | The vendor states tag value cardinality does not affect overall performance (not checked here); wide schemas and many tags still cost | Fewer tags, sorted by query priority |
| Prometheus | Metric name + label set | Each series adds RAM, CPU, disk and network cost | Keep labels bounded and few |
| TimescaleDB | Value of the identity columns | Compression ratio and index size | segmentby choice, integer IDs |
| QuestDB | Value of SYMBOL columns | Symbol dictionary memory | SYMBOL for bounded values, VARCHAR/UUID for unique |
The relational fix: a series dictionary
In SQL a common answer is to separate identity from readings. A series table holds one row per physical point with a unique key on the identity columns; readings carry only an integer series_id. Slowly changing attributes such as firmware become ordinary columns updated in place (or rows in a history table if you need to know when they changed), so they never create new series. This continues the same run:
CREATE TABLE series (
series_id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
site text NOT NULL,
asset text NOT NULL,
point text NOT NULL,
firmware text, -- attribute, updated in place, not part of the identity
UNIQUE (site, asset, point)
);
CREATE TABLE reading (
series_id integer NOT NULL REFERENCES series,
ts timestamptz NOT NULL,
value double precision NOT NULL,
PRIMARY KEY (series_id, ts)
);
INSERT INTO series (site, asset, point, firmware)
SELECT DISTINCT ON (site, asset, point) site, asset, point, firmware
FROM telemetry_raw ORDER BY site, asset, point, ts DESC; -- latest firmware wins
INSERT INTO reading
SELECT s.series_id, r.ts, r.value
FROM telemetry_raw r JOIN series s USING (site, asset, point);
SELECT count(*) AS series, count(*) FILTER (WHERE firmware = 'v2.4.1') AS on_new_firmware FROM series;
-- series | on_new_firmware
-- --------+-----------------
-- 240 | 120Equivalent modelling in tag-based engines
- InfluxDB: keep site, asset and point as tags; write firmware and message IDs as fields; keep the asset register in a relational database and join in the application or query layer.
- Prometheus: if building points must be exposed as metrics, keep labels to bounded identity values and keep descriptive attributes out of the label set.
- QuestDB: SYMBOL for site, asset and point; VARCHAR or UUID for message identifiers.
Trade-offs, limitations and when cardinality is not your problem
- Moving a value out of the identity loses it as a cheap filter. Filtering by firmware now needs a join or a field filter. That is usually acceptable for attributes queried rarely.
- Legitimately large identity sets exist. A smart-meter fleet of a million meters has a million series however it is modelled. Then the question becomes engine choice and sizing, not tag hygiene; see the selection guide and what drives performance.
- Vendor statements are version-specific. InfluxDB 1.x/2.x and InfluxDB 3 are documented as behaving differently, and advice written for one may not apply to the other. Check which engine generation you run before applying a fix.
- Cardinality is not the cause of every slowdown. If series counts are stable and bounded by the asset register, look at query shape, time ranges, indexes and partitioning first (time-series indexing).
Operations and governance
- Gate the schema at ingestion. Map incoming JSON keys to an allow-list of tags; send unknown keys to fields or a quarantine table. This belongs with the other data quality checks before ingestion.
- Monitor series counts. Track distinct series per day and new series per day; alert on sudden jumps, which usually mean a firmware change or a mis-mapped field.
- Tenant and security tags. In multi-tenant systems a tenant tag is bounded and useful for access control, but do not put user or session identifiers in tags.
- Clean-up. Deleting high-cardinality data is engine-specific; in InfluxDB 2.x the documentation lists reviewing tags, improving the schema and deleting high-cardinality data as the resolution steps.
Frequently asked questions
What is high cardinality in time-series data?
A large number of distinct series, usually caused by tag or label values that are unique or nearly unique per reading, such as message IDs, user IDs or hashes. Cardinality multiplies across tags, so a few such tags can produce millions of series.
Is high cardinality still a problem in InfluxDB 3?
The InfluxDB 3 documentation states that its storage engine supports infinite tag value and series cardinality and that tag value cardinality does not affect overall performance; that is the vendor's claim and could not be checked here. Schema design still matters: tags form the primary key with the timestamp, and wide schemas and many tags have their own costs.
How many labels should a Prometheus metric have?
The Prometheus instrumentation guidance suggests, as a general guideline, keeping the cardinality of metrics below 10 and investigating alternatives for metrics over 100; it says the vast majority of metrics should have no labels.
How do I measure series cardinality in SQL?
Count distinct combinations of the identity columns, for example SELECT count(*) FROM (SELECT DISTINCT site, asset, point FROM telemetry) s, and count new combinations per day to see churn.
Should a device ID be a tag?
In our editorial view, usually yes: it is bounded by the number of devices and is a common filter. Message IDs, sequence numbers and request IDs are unbounded, which the InfluxDB and Prometheus guidance warns against putting in tags or labels.
Sources
- Prometheus docs: Metric and label naming
- Prometheus docs: Instrumentation
- Prometheus docs: Data model
- InfluxDB v2 docs: Resolve high series cardinality
- InfluxDB v2 docs: Glossary
- InfluxDB 3 Core docs: Schema design recommendations
- InfluxDB 3 Cloud Dedicated docs: Schema design recommendations
- Tiger Data docs: Basic compression with hypercore
- QuestDB docs: Symbol
- InfluxDB OSS v1 docs: Frequently asked questions
- InfluxDB OSS v1 docs: Schema design and data layout
Checked 27 October 2026.
How we research these articles: our editorial method. Research-based from official documentation; SQL examples are run on a local database where the page says so. CodeWithSQL earns nothing from the vendors mentioned.