skip to content
Posts · December 2025

Saving space in ClickHouse using low cardinality types


Low Cardinality Types

ClickHouse allows us to define certain columns as LowCardinality(Data_type), these columns apply dictionary encoding to the data. This can massively reduce the space occupied by categorical columns on disk. Instead of storing the actual data it replaces it with short integer codes, integer more often than not require less space and compress way better than strings. You will be able to get the data, or join with any other table using the underlying value without making any changes.

This can significantly speed up our queries, with LowCardinality, the query engine can often perform operations like filtering, grouping, and counting directly on the small integer keys instead of the full string values. This reduces the amount of data read from disk and the CPU work needed to process it.

Low cardinality types are more versatile than enums because you do not need to pre define values and any new value is automatically handled. According to the docs low cardinality types more often than not are more efficient when there are fewer than ~10,000 unique values; beyond that, use the ordinary data type.

You can define the following low cardinality types in your table like this:

CREATE TABLE category_data
(
`product_type` LowCardinality(String),
`product_sku_id` LowCardinality(FixedString(16))
)
ENGINE = MergeTree()
ORDER BY product_sku_id

You can also use LowCardinality types for numbers (not decimal) and date also, but for data types with fixed size 8 bytes or less you will have to enable allow_suspicious_low_cardinality_types setting. For small values LowCardinality is usually inefficient and not worth it.

There is some added cost to maintain low cardinality types - ClickHouse needs to maintain a dictionary to map the values, and bring this in memory to encode and decode values. There will also be a similar overhead when joining a table on low cardinality types.

Checking how much space we can save when using low cardinality types

We will do a comparison for 10,100, 1000, 10000 unique string values in 1 million, 10 million and 100 million rows. All values will have a uniform distribution.

Sample table:

CREATE TABLE orders
(
order_id UInt64,
amount Float64,
status_str String, -- 10 unique
status_lc LowCardinality(String),
country_str String, -- 100 unique
country_lc LowCardinality(String),
brand_str String, -- 1,000 unique
brand_lc LowCardinality(String),
sku_str String, -- 10,000 unique
sku_lc LowCardinality(String)
)
ENGINE = MergeTree
ORDER BY order_id;

Adding example data to the table:

INSERT INTO orders
SELECT
order_id, amount,
status, status,
country, country,
brand, brand,
sku, sku
FROM
(
SELECT
number AS order_id,
round(cityHash64(number, 0) % 1000000 / 100, 2) AS amount,
concat('st_', lpad(toString(cityHash64(number, 1) % 10), 5, '0')) AS status,
concat('co_', lpad(toString(cityHash64(number, 2) % 100), 5, '0')) AS country,
concat('br_', lpad(toString(cityHash64(number, 3) % 1000), 5, '0')) AS brand,
concat('sk_', lpad(toString(cityHash64(number, 4) % 10000), 5, '0')) AS sku
FROM numbers(1000000)
);

You can see all the queries on the ClickHouse fiddle.

You can check how much disk space we are saving (compressed bytes)

PairCardinality@ 1M@ 10M@ 100M
status1064.5% smaller65.6% smaller65.3% smaller
country10066.0% smaller66.8% smaller66.7% smaller
brand1,00036.6% smaller36.6% smaller36.7% smaller
sku10,0008.9% smaller8.9% smaller5.9% smaller

Compressed size per column

Column@ 1M@ 10M@ 100M
order_id3.82 MiB38.19 MiB381.94 MiB
amount4.93 MiB49.30 MiB493.02 MiB
status_str2.62 MiB26.23 MiB262.33 MiB
status_lc0.93 MiB9.03 MiB91.05 MiB
country_str2.89 MiB28.87 MiB288.66 MiB
country_lc0.98 MiB9.59 MiB95.98 MiB
brand_str3.03 MiB30.32 MiB303.20 MiB
brand_lc1.92 MiB19.21 MiB191.92 MiB
sku_str3.82 MiB38.19 MiB381.82 MiB
sku_lc3.48 MiB34.80 MiB359.18 MiB

Given the way I am generating example data - all consecutive values are non repeating, in actual data sets that may not be case and that might further help the compression and the columns will occupy even less space. Also these are for tables whose parts have not been merged so the numbers might not be exact.

Also using LowCardinality types can make the queries more efficient and take up less time. Here is another comparison for query speed using where clause and group by on country_str and country_lc - ClickHouse fiddle

TestMetricStringLowCardinalityImprovement
WHERE country = 'co_00042'Query time20 ms6 ms3.33× faster
Bytes read110,000,000 (104.90 MiB)10,000,000 (9.54 MiB)11× less I/O
Peak memory2,732,872 (2.61 MiB)2,826,684 (2.70 MiB)~same
User CPU time99,886 µs27,476 µs3.64× less CPU
System CPU time12,277 µs1,327 µs9.25× less CPU
Rows read10,000,00010,000,000same
GROUP BY countryQuery time27 ms10 ms2.70× faster
Bytes read110,000,000 (104.90 MiB)10,000,000 (9.54 MiB)11× less I/O
Peak memory9,238,649 (8.81 MiB)3,467,738 (3.31 MiB)2.66× less memory
User CPU time159,854 µs62,338 µs2.56× less CPU
System CPU time4,185 µs3,007 µs1.39× less CPU
Rows read10,000,00010,000,000same

Read Query data is not deterministic and can depend on several factors, but we can still see, low cardinality types can improve our query performance. The exact number might not be correct and can vary.

Converting an existing column to Low Cardinality type

Migrating an existing column to LowCardinality is a single ALTER TABLE statement:

ALTER TABLE events
MODIFY COLUMN status LowCardinality(String);

This triggers a background mutation that rewrites the entire data - in large tables this can be a very expensive option - better to schedule it accordingly.