Concluded·3 Aug 2026 · 5 min read

ClickHouse against TimescaleDB, ten million readings, same file into both

ClickHouse 24.8 · TimescaleDB pg17 · Docker Compose · Proxmox LXC

Ten million readings, two engines, one box. The rule I set was that both had to be loaded from the same file, because two separate loading scripts would have meant measuring my own code rather than the databases.

The rig

HostProxmox LXC, 6 vCPU, 9 GB RAM, both engines co-resident
ClickHouse24.8, MergeTree ORDER BY (tag, ts)
TimescaleDBpg17, hypertable on ts, 1.5 GB shared buffers
Dataset10,000,000 rows · 1,000 tags · 10,000 samples · 2h 46m

The data is the output of the ten-million-point generator — six signal kinds shaped like plant behaviour. That choice is load-bearing rather than decorative: the same volume of uniform random numbers compresses about five times worse, so a benchmark built on noise would have made both engines look far more expensive to run than they are.

A bug I had to fix first

The file generator wrote ten million rows and reported ten million rows, and was wrong. It held its position in the tag loop as a for variable, and resumed from a backpressure event by re-entering the function — which quietly reset that variable to zero, duplicating rows within a sample and skipping the rest of it.

The row count was exactly right, which is what made it hard to see. What gave it away was the last timestamp in the file: 1,000 tags × 10,000 samples should end at 02:46:39 and it ended at 01:23:20. Fixed by holding the position outside the loop, and verified by counting distinct (ts, tag) pairs — 10,000,000 for 10,000,000 rows.

Worth stating plainly: had I not checked, every number below would have been computed over a dataset with duplicate rows, and duplicates compress beautifully. The compression figures would have been flattering nonsense.

Loading

TimeRows/sec
ClickHouse3.75 s2,666,667
TimescaleDB41.2 s242,600

LOADING THE SAME 10,000,000-ROW FILE

ClickHouse2,666,667 rows/sec
3.75s
TimescaleDB242,600 rows/sec
41.2s
0s15s30s45s
The same file, the same box. Loading is the one place the gap is not close.

11× apart, on the identical file.

Storage

On diskBytes per reading
TimescaleDB, uncompressed698 MB73.2
ClickHouse, default codec (LZ4)41.81 MiB4.38
TimescaleDB, compressed16 MB1.68
ClickHouse, tuned codecs6.35 MiB0.67

WHAT TEN MILLION READINGS COST TO KEEP

TimescaleDB, uncompressed73.2 bytes each
698 MB
ClickHouse, default codec4.38 bytes each
43.8 MB
TimescaleDB, compressed1.68 bytes each
16 MB
ClickHouse, tuned codecs0.67 bytes each
6.66 MB
Same ten million readings, four ways of keeping them. The bottom two are the only ones worth considering, and they are ten times apart from the top.

ClickHouse's default came out worse than compressed TimescaleDB, which was not what I expected from a column store with that reputation. The default codec is LZ4, chosen for speed rather than size. Giving the table the encodings that suit time-series data — DoubleDelta on the timestamp, Gorilla on the float, both inside ZSTD(3) — changed it completely:

CREATE TABLE points_zstd (
  ts    DateTime64(3) CODEC(DoubleDelta, ZSTD(3)),
  tag   LowCardinality(String),
  kind  LowCardinality(String),
  value Float64       CODEC(Gorilla, ZSTD(3))
) ENGINE = MergeTree ORDER BY (tag, ts);

Rewriting the same ten million rows into it took 2.3 seconds.

SETTING CLICKHOUSE CODECS — WHAT CHANGES

DISK USED
41.8 MB
default
6.35 MB
tuned
QUERY TIME
104 ms
default
107 ms
tuned
Six point six times less disk, and the queries did not notice. There is no trade-off here to think about — the default is simply wrong for a historian.
Terminal output comparing the two ClickHouse tables on disk: 41.81 MiB with the default codec against 6.35 MiB with DoubleDelta, Gorilla and ZSTD, for the same ten million rows, with query times within a few milliseconds of each other.
Same rows, same queries, one table definition apart.

6.6× less disk, and every query landed within a few milliseconds of where it was. This is not a speed-versus-size decision to weigh up. It is a default that does not suit the job, and it is invisible unless you go looking.

Queries

Best of three runs, warm cache, milliseconds. These are the shapes a historian gets asked, not synthetic ones.

QueryClickHouseCH tunedTimescale rawTimescale compressed
last value for every tag10410711,204508
one tag, raw 10-minute trace77768157
one tag, 1-minute averages767624759
whole estate, 1-minute averages1671761,2021,955
worst 20 vibration tags8887311155

QUERY TIMES, BEST OF THREE, MILLISECONDS

ClickHouseClickHouse tunedTimescale rawTimescale compressed
last value, every tag
104
107
11,204
508
one tag, raw 10 min
77
76
81
57
one tag, 1-min averages
76
76
247
59
whole estate, 1-min avg
167
176
1,202
1,955
worst 20 vibration tags
88
87
311
155
Read it by row, not by column. No configuration wins everything, and the row you care about is whichever question your plant asks most.

The last-value query is brutal on an uncompressed hypertable. 11.2 seconds against 104 ms — 108× — for the single most routine question a control room asks. Compression takes it to 508 ms, usable, still 5× behind.

In fairness to TimescaleDB, this is a known pattern with known answers: continuous aggregates maintaining a last-value table, or SkipScan on a (tag, ts DESC) index. I measured the query somebody writes first, not the query somebody writes after reading the tuning guide. Anyone deploying it in anger would fix this, and it is worth knowing it needs fixing.

Single-tag reads go the other way. Compressed TimescaleDB beat ClickHouse on both — 57 ms against 77, and 59 ms against 76. Pulling one instrument's trend is what operators do dozens of times a shift, and on that workload the row-store with compression is genuinely faster.

Compression made one query slower. The whole-estate rollup went 1,202 ms → 1,955 ms after compressing. A query that touches every row has to decompress every row on the way past. Compression pays on selective reads and charges on full scans, and it is worth knowing which of those your dashboards actually do.

THE FOUR NUMBERS

11×
FASTER TO LOAD (CLICKHOUSE)
108×
SLOWER LAST-VALUE (TIMESCALE, RAW)
6.6×
DISK WASTED BY DEFAULT CODEC
0.67
BYTES PER READING, BEST CASE
Three of these favour ClickHouse. The fourth is free to fix and most people running ClickHouse have not.

Verdict

CHOOSING BY WORKLOAD, NOT BY BENCHMARK

WHAT DOES YOUR PLANT ASK MOST?ten million readings, either wayCLICKHOUSETIMESCALEDBquestions across the whole estateingesting hard, keeping years11x faster to load, 2.5x smallerone instrument's trend, all dayyou already run Postgresfaster on single-tag readsset the codecs either way — that one is free
The honest answer depends on the question your plant asks most often, not on which engine benchmarks better in general.

ClickHouse for hard ingest, long retention, and questions that sweep the estate. Eleven times faster to load, two and a half times smaller than compressed Timescale once its codecs are set, and several times quicker on every aggregate that crosses tags.

TimescaleDB if the day-to-day is one instrument at a time, if Postgres is already in the building, or if anything downstream expects to speak SQL to an ordinary database. That last one is worth more than it looks on a benchmark page. Grafana, the reporting tool, the application written in 2019 and the colleague who already knows Postgres are all real costs on one side and real savings on the other.

An honest limit on all of this. Ten million readings is roughly three hours of a thousand-tag plant. Both engines are comfortable at that size, and at this scale the right way to choose is operational fit rather than milliseconds. The gap widens with volume, which is when the load and storage numbers start to decide it.

The one change worth making on Monday, whichever you run, is the codec setting.

The argument, without the measurements, is in the article.

Newsletter

New essays, by email.

SCADA, cloud, AI, and the plant floor — a short email when something new is published. No noise, unsubscribe anytime.