~$ sven.eliasson

24 Feb 2026 · 13 min read

ClickHouse Compression Codecs for Time-Series Data: A Benchmark

  • clickhouse
  • compression
  • time-series
  • benchmark

I benchmarked five compression codec setups on 100 million rows of synthetic time-series data in ClickHouse 25.11, from plain default LZ4 to per-column codecs (DoubleDelta, Delta, Gorilla) combined with ZSTD. I measured three things: storage size, query latency (cold and warm), and ingest speed.

The short version:

  1. Per-column codecs + ZSTD(3) compress 4.23×, default LZ4 only 1.67×. Almost all of that comes from two columns: sorted timestamps (872×) and monotone counters (843×).
  2. ZSTD(9) is not worth it. Same compression as ZSTD(3), but 28% slower ingest.
  3. Better compression does not mean faster queries. Heavily compressed columns query faster cold, but ZSTD costs CPU on every read, so plain LZ4 often wins warm.
  4. Gorilla can make float data bigger. It only helps on smooth, slowly-changing values.

All data, scripts and plots are in comino/blog-experiments. Numbers below are median ± IQR over 10 runs.

Setup

One caveat up front: the whole dataset fits in RAM (543 MB to 1.37 GB depending on codec). So my “cold” numbers show NVMe re-read plus decompression, not the sustained disk I/O you would see when data is much bigger than memory. More on that below.

Data Generation

The 100M rows come from ClickHouse’s numbers() function:

INSERT INTO exp01_compression.source
SELECT
    toDateTime('2024-01-01 00:00:00') + intDiv(number, 50) AS timestamp,
    ['cpu_usage','disk_io','http_requests_total','mem_free','net_bytes_sent']
        [1 + (number % 5)] AS metric_name,
    50 + 30 * sin(number / 1000.0) + (rand(number) % 1000) / 2000.0 AS value,
    concat('host-', toString(number % 50)) AS host,
    ['us-east','us-west','eu-central','ap-south'][1 + (rand(number + 1) % 4)] AS region,
    intDiv(number, 50) AS counter
FROM numbers(100000000);

That gives me:

All tables use ORDER BY (metric_name, host, timestamp). After insert, OPTIMIZE TABLE … FINAL merged everything into a single part.

This data is ideal for compression: perfectly regular timestamps, perfectly monotone counters. Real data is messier and will compress less. Keep that in mind for every ratio below.

The Five Variants

-- V1: Default (LZ4)
CREATE TABLE v1_default (
    timestamp DateTime,
    metric_name LowCardinality(String),
    value Float64,
    host LowCardinality(String),
    region LowCardinality(String),
    counter UInt64
) ENGINE = MergeTree()
ORDER BY (metric_name, host, timestamp);

-- V2: ZSTD(3) on all columns
CREATE TABLE v2_zstd (
    timestamp DateTime CODEC(ZSTD(3)),
    metric_name LowCardinality(String) CODEC(ZSTD(3)),
    value Float64 CODEC(ZSTD(3)),
    host LowCardinality(String) CODEC(ZSTD(3)),
    region LowCardinality(String) CODEC(ZSTD(3)),
    counter UInt64 CODEC(ZSTD(3))
) ENGINE = MergeTree()
ORDER BY (metric_name, host, timestamp);

-- V3: Per-column specialized + LZ4
CREATE TABLE v3_percolumn (
    timestamp DateTime CODEC(DoubleDelta, LZ4),
    metric_name LowCardinality(String) CODEC(LZ4),
    value Float64 CODEC(Gorilla(8), LZ4),
    host LowCardinality(String) CODEC(LZ4),
    region LowCardinality(String) CODEC(LZ4),
    counter UInt64 CODEC(Delta(8), LZ4)
) ENGINE = MergeTree()
ORDER BY (metric_name, host, timestamp);

-- V4: Per-column specialized + ZSTD(3)
CREATE TABLE v4_percolumn_zstd (
    timestamp DateTime CODEC(DoubleDelta, ZSTD(3)),
    metric_name LowCardinality(String) CODEC(ZSTD(3)),
    value Float64 CODEC(Gorilla(8), ZSTD(3)),
    host LowCardinality(String) CODEC(ZSTD(3)),
    region LowCardinality(String) CODEC(ZSTD(3)),
    counter UInt64 CODEC(Delta(8), ZSTD(3))
) ENGINE = MergeTree()
ORDER BY (metric_name, host, timestamp);

-- V5: Same as V4, but ZSTD(9) on most columns
CREATE TABLE v5_aggressive (
    timestamp DateTime CODEC(DoubleDelta, ZSTD(9)),
    metric_name LowCardinality(String) CODEC(ZSTD(9)),
    value Float64 CODEC(Gorilla(8), ZSTD(3)),
    host LowCardinality(String) CODEC(ZSTD(9)),
    region LowCardinality(String) CODEC(ZSTD(9)),
    counter UInt64 CODEC(Delta(8), ZSTD(9))
) ENGINE = MergeTree()
ORDER BY (metric_name, host, timestamp);

The (8) in Gorilla(8) and Delta(8) is the value width in bytes (Float64/UInt64 = 8). In V5, value stays on ZSTD(3): the Gorilla output of noisy floats is basically incompressible, so a higher ZSTD level has nothing to work with.

Storage

Sizes come from system.parts_columns (column data only, one merged part per table):

VariantCompressedUncompressedRatio
V1 (LZ4 default)1,374.5 MB2,300.9 MB1.67×
V2 (ZSTD all)864.7 MB2,301.1 MB2.66×
V3 (per-col + LZ4)673.6 MB2,301.1 MB3.42×
V4 (per-col + ZSTD)543.4 MB2,301.1 MB4.23×
V5 (aggressive)543.4 MB2,301.1 MB4.23×

V4 and V5 differ by 141 KB on 543 MB. The specialized pre-codecs do the heavy lifting; raising ZSTD from level 3 to 9 buys nothing here.

Where the difference comes from, per column:

ColumnTypeV1V3V4V1 RatioV3 RatioV4 Ratio
timestampDateTime401.7 MB477 KB459 KB1.00×839×872×
counterUInt64420.1 MB977 KB949 KB1.90×818×843×
valueFloat64551.2 MB634.5 MB541.7 MB1.45×1.26×1.48×
metric_nameLC(String)482 KB470 KB111 KB208×213×908×
hostLC(String)484 KB471 KB112 KB207×213×898×
regionLC(String)486 KB473 KB116 KB206×212×867×

Why 872× on timestamps? The data is sorted, so within each (metric_name, host) group the timestamps step by exactly 1 second. DoubleDelta turns that into near-constant zeros, and ZSTD squeezes those to almost nothing. Plain LZ4 without a pre-codec gets 1.0×: it simply cannot see the pattern. Same story for the counter column with Delta.

Why does value barely compress? It’s a sine wave plus random noise. Noise has no pattern to exploit. Worse: V3 (Gorilla+LZ4) lands at 1.26×, below plain LZ4’s 1.45×. Gorilla’s XOR encoding actually expanded the noisy data.

When Gorilla Helps

Gorilla XORs each value with the previous one and stores only the changed bits. Nearly-identical neighbors compress great; noisy neighbors change all the bits and add overhead. I tested four distributions on 10M-row tables:

DistributionDescriptionLZ4 RatioGorilla+LZ4 Ratio
Monotonef(t) = t × 0.0011.74×44.7×
Spiky99% zeros, 1% spikes1.62×30.8×
Sinus + noisesmooth + noise1.31×2.48×
Randomuniform random1.05×1.29×

Gorilla compression by data distribution

Rule of thumb:

Query Performance

I ran 10 queries covering common access patterns:

-- Q1: Point lookup (1 host, 1 hour)
SELECT avg(value) FROM {table}
WHERE metric_name='cpu_usage' AND host='host-0'
  AND timestamp BETWEEN '2024-01-01 00:00:00' AND '2024-01-01 01:00:00';

-- Q2: Range scan (1 metric, 7 days)
SELECT avg(value) FROM {table}
WHERE metric_name='cpu_usage'
  AND timestamp BETWEEN '2024-01-01' AND '2024-01-08';

-- Q3: Top-K GROUP BY host
SELECT host, count(), avg(value) FROM {table}
WHERE metric_name='cpu_usage'
GROUP BY host ORDER BY count() DESC LIMIT 10;

-- Q4: Point lookup (1 host, 1 hour, multi-agg)
SELECT avg(value), min(value), max(value) FROM {table}
WHERE metric_name='cpu_usage' AND host='host-3'
  AND timestamp BETWEEN '2024-01-05 12:00:00' AND '2024-01-05 13:00:00';

-- Q5: Full table scan, touches the value column
SELECT count() FROM {table} WHERE value > 60;

-- Q6: Heavy aggregation, reads all columns
SELECT metric_name, host, avg(value), sum(counter) FROM {table}
GROUP BY metric_name, host ORDER BY avg(value) DESC LIMIT 20;

-- Q7: p99 percentile on value
SELECT metric_name, quantile(0.99)(value) AS p99 FROM {table}
GROUP BY metric_name;

-- Q8: Distinct counts on string columns
SELECT uniqExact(metric_name), uniqExact(host) FROM {table};

-- Q9: Last value per host (argMax)
SELECT host, argMax(value, timestamp) AS last_value FROM {table}
WHERE metric_name='cpu_usage' GROUP BY host;

-- Q10: Moving average (window function)
SELECT timestamp, avg(value) OVER (ORDER BY timestamp
  ROWS BETWEEN 59 PRECEDING AND CURRENT ROW) AS ma60
FROM {table}
WHERE metric_name='cpu_usage' AND host='host-0'
  AND timestamp BETWEEN '2024-01-01' AND '2024-01-02'
ORDER BY timestamp;

Cold (median ± IQR, ms, n=10)

QueryPatternV1 LZ4V2 ZSTDV3 per-col+LZ4V4 per-col+ZSTDV5 aggressive
Q01Point lookup16 ± 116 ± 116 ± 016 ± 216 ± 2
Q02Range 7d29 ± 328 ± 226 ± 125 ± 125 ± 2
Q03Top-K hosts130 ± 14131 ± 6146 ± 3141 ± 12140 ± 10
Q04Point lookup4 ± 14 ± 14 ± 14 ± 04 ± 1
Q05Full scan WHERE229 ± 4230 ± 16252 ± 11243 ± 12234 ± 13
Q06Multi-col agg905 ± 37783 ± 18888 ± 23819 ± 9807 ± 33
Q07Percentile p99401 ± 42365 ± 10488 ± 21480 ± 28477 ± 11
Q08Distinct count373 ± 17375 ± 24372 ± 20365 ± 21362 ± 26
Q09argMax179 ± 5169 ± 5141 ± 7146 ± 7146 ± 7
Q10Window func18 ± 119 ± 119 ± 218 ± 120 ± 1

Warm (median ± IQR, ms, n=10)

QueryPatternV1 LZ4V2 ZSTDV3 per-col+LZ4V4 per-col+ZSTDV5 aggressive
Q01Point lookup5 ± 05 ± 05 ± 15 ± 15 ± 1
Q02Range 7d9 ± 011 ± 011 ± 112 ± 012 ± 1
Q03Top-K hosts25 ± 042 ± 150 ± 166 ± 267 ± 6
Q04Point lookup4 ± 14 ± 04 ± 04 ± 04 ± 0
Q05Full scan WHERE16 ± 044 ± 360 ± 486 ± 590 ± 4
Q06Multi-col agg544 ± 43620 ± 37726 ± 37639 ± 31637 ± 34
Q07Percentile p99116 ± 5189 ± 19221 ± 8272 ± 12285 ± 13
Q08Distinct count375 ± 28362 ± 24352 ± 21358 ± 32377 ± 29
Q09argMax30 ± 251 ± 257 ± 272 ± 569 ± 7
Q10Window func6 ± 17 ± 07 ± 07 ± 07 ± 1

Query latency heatmap: cold vs warm

What the numbers say

Warm queries: LZ4 wins almost everywhere. Look at Q05: V1 drops from 229 ms cold to 16 ms warm. V4 only drops from 243 ms to 86 ms. Once data sits in the page cache, V1 pays almost nothing for decompression, while V4 pays the ZSTD CPU cost on every single read. That cost doesn’t go away with caching.

Cold queries: compression helps where it actually compressed something. Q06 reads the counter column, which is 420 MB in V1 but under 1 MB in V4. Less data to read, faster cold query (819 ms vs 905 ms). Q09 benefits the same way from the tiny timestamp column.

Queries on the noisy value column: LZ4 wins, cold and warm. Q07 scans value, which is roughly the same size in every variant. Same bytes to read, but ZSTD is about 3× more expensive to decompress than LZ4. V1: 401 ms cold. V4: 480 ms.

Tiny queries don’t care. Point lookups (Q01, Q04, Q10) read a few granules; all variants are identical.

To check the “ZSTD costs CPU” explanation, I pulled ProfileEvents from system.query_log for warm Q05: both variants read the same 320 MB and 40M rows, disk time is nearly identical, but V4 burns 6.8× more CPU than V1 (1.3s vs 0.19s summed across threads). That’s the decompression. I only profiled this one query; the pattern is plausible for the other value-heavy queries but I did not verify each one.

ProfileEvents Q05 warm: V1 vs V4

A note on “cold” here: the dataset fits in RAM and the NVMe is fast, so cold vs warm mostly measures re-read overhead, not real disk pressure. On a system where data is much bigger than RAM, V4’s 2.5× smaller footprint would matter much more than it does in my numbers. My results understate the cold-query benefit of good compression.

Ingest

Single-threaded INSERT from a source table, three batch sizes, 10 runs each (median rows/s ± IQR):

Variant10K batch100K batch1M batch
V1 (LZ4)147K ± 8K1,250K ± 31K5,666K ± 290K
V2 (ZSTD)144K ± 10K1,205K ± 148K4,651K ± 152K
V3 (per-col LZ4)147K ± 4K1,220K ± 98K4,808K ± 396K
V4 (per-col ZSTD)141K ± 23K1,198K ± 87K4,963K ± 333K
V5 (aggressive)137K ± 12K1,130K ± 51K3,559K ± 341K

Ingest throughput by batch size

At small batches, per-INSERT overhead dominates and all variants look the same. At 1M-row batches:

So Which One?

There is no single winner. Pick by workload:

Your WorkloadBest ChoiceWhy
Storage-constrained (cloud, cost per GB matters)V42.5× smaller than V1
Cold-query-heavy (data exceeds RAM)V4Fewer disk reads beat decompression cost
Ingest-heavy (writes matter most)V1Fastest ingest, minimal CPU
Warm-query-heavy (dashboards, data in RAM)V1LZ4 is nearly free when cached
Don’t want to think about codecsV2One ZSTD(3) everywhere, 60% smaller, done

For reference, the averages across all ten queries (mean of per-query medians):

VariantRatioAvg Cold (ms)Avg Warm (ms)Ingest 1M (rows/s)
V11.67×2291135.67M
V22.66×2121344.65M
V33.42×2351494.81M
V44.23×2261524.96M
V54.23×2231563.56M

Combined overview: compression × latency × ingest

And as a decision tree:

Is your column a sorted timestamp?
  → Yes: DoubleDelta + ZSTD(3)                    [872× on sorted 1s intervals]
  → No: Is it a monotone counter?
    → Yes: Delta(8) + ZSTD(3)                      [843× on strictly monotone]
    → No: Is it a Float?
      → Smooth/slowly changing?  → Gorilla(8) + LZ4   [up to 45× on monotone floats]
      → Noisy/random?           → LZ4 default         [Gorilla hurts here]
    → String/LowCardinality?    → ZSTD(3)             [867–908×]

The takeaways I’d actually carry into production:

  1. Per-column codec choice is where the value is. V1 to V4 is 1.67× to 4.23×, and that comes from DoubleDelta and Delta, not from ZSTD.
  2. Skip ZSTD(9). On this data it compressed nothing extra and cost 28% ingest.
  3. Don’t put Gorilla on noisy floats. Measure first.
  4. Measure on your own data. My 800×+ ratios need perfectly regular intervals. Real metrics with variable scrape intervals will land somewhere between 10× and 100× on timestamps. Still great, just not 872×.

Limitations

Honest list, so you can judge how far these numbers carry:

  1. Synthetic, perfectly regular data. Real metrics compress less.
  2. Everything fits in RAM, single merged part, single-threaded queries. Production has many parts, concurrent load, and data bigger than memory. Each of these shifts the trade-offs, mostly in favor of better compression.
  3. Fixed query order, no randomization. Warm runs could carry cache effects between queries.
  4. I profiled decompression CPU for one query only (Q05). The explanation fits the other results but isn’t individually proven.
  5. FPC codec not tested. It targets floats and might beat both LZ4 and Gorilla on the noisy value column.

References

  1. Pelkonen, T., et al. “Gorilla: A Fast, Scalable, In-Memory Time Series Database.” VLDB 2015. PDF
  2. ClickHouse Compression Documentation
  3. Optimize Codecs & Schema (ClickHouse Blog)
  4. Altinity: New Encodings to Improve ClickHouse (2019)

Tested on ClickHouse 25.11.3.54, Hetzner CX53 (16 vCPU, 32 GB RAM, NVMe SSD). Raw data, scripts and plots: github.com/comino/blog-experiments.


Questions, corrections, or a consulting project? Mail me or find me on GitHub.