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_idYou 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 = MergeTreeORDER BY order_id;Adding example data to the table:
INSERT INTO ordersSELECT order_id, amount, status, status, country, country, brand, brand, sku, skuFROM( 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)
| Pair | Cardinality | @ 1M | @ 10M | @ 100M |
|---|---|---|---|---|
status | 10 | 64.5% smaller | 65.6% smaller | 65.3% smaller |
country | 100 | 66.0% smaller | 66.8% smaller | 66.7% smaller |
brand | 1,000 | 36.6% smaller | 36.6% smaller | 36.7% smaller |
sku | 10,000 | 8.9% smaller | 8.9% smaller | 5.9% smaller |
Compressed size per column
| Column | @ 1M | @ 10M | @ 100M |
|---|---|---|---|
order_id | 3.82 MiB | 38.19 MiB | 381.94 MiB |
amount | 4.93 MiB | 49.30 MiB | 493.02 MiB |
status_str | 2.62 MiB | 26.23 MiB | 262.33 MiB |
status_lc | 0.93 MiB | 9.03 MiB | 91.05 MiB |
country_str | 2.89 MiB | 28.87 MiB | 288.66 MiB |
country_lc | 0.98 MiB | 9.59 MiB | 95.98 MiB |
brand_str | 3.03 MiB | 30.32 MiB | 303.20 MiB |
brand_lc | 1.92 MiB | 19.21 MiB | 191.92 MiB |
sku_str | 3.82 MiB | 38.19 MiB | 381.82 MiB |
sku_lc | 3.48 MiB | 34.80 MiB | 359.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
| Test | Metric | String | LowCardinality | Improvement |
|---|---|---|---|---|
WHERE country = 'co_00042' | Query time | 20 ms | 6 ms | 3.33× faster |
| Bytes read | 110,000,000 (104.90 MiB) | 10,000,000 (9.54 MiB) | 11× less I/O | |
| Peak memory | 2,732,872 (2.61 MiB) | 2,826,684 (2.70 MiB) | ~same | |
| User CPU time | 99,886 µs | 27,476 µs | 3.64× less CPU | |
| System CPU time | 12,277 µs | 1,327 µs | 9.25× less CPU | |
| Rows read | 10,000,000 | 10,000,000 | same | |
GROUP BY country | Query time | 27 ms | 10 ms | 2.70× faster |
| Bytes read | 110,000,000 (104.90 MiB) | 10,000,000 (9.54 MiB) | 11× less I/O | |
| Peak memory | 9,238,649 (8.81 MiB) | 3,467,738 (3.31 MiB) | 2.66× less memory | |
| User CPU time | 159,854 µs | 62,338 µs | 2.56× less CPU | |
| System CPU time | 4,185 µs | 3,007 µs | 1.39× less CPU | |
| Rows read | 10,000,000 | 10,000,000 | same |
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 eventsMODIFY 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.