Why ClickHouse Is So Fast: 7 Design Choices, Measured on 1 Billion Rows

Unveiling the design choices behind the world's fastest real-time analytics DBMS

Why ClickHouse is so fast is best answered with a number: on a two-core machine with 7 GB of memory, it averaged a column across 1,000,000,000 rows in 1.06 seconds, roughly 940 million rows per second, and answered a per-tenant question over the same billion rows in 8 milliseconds by reading 7 granules out of 122,127. Neither result needed a cluster, a cache or a ClickHouse performance tuning pass. Both follow from seven design choices that run through every layer of the engine, and together they are why ClickHouse is so fast at any scale we have measured.

This article measures each of those seven choices on ClickHouse 26.7 against a billion-row events table we generated for the purpose, with the relevant setting switched off wherever the engine allows it so that the contribution is isolated rather than asserted. It also places our lab numbers next to the published figures from the largest ClickHouse deployments in the world, clearly marked as theirs, because the point of understanding why ClickHouse is so fast is knowing how far the same mechanisms carry.

1,000,000,000
rows in the events table under test: 500 tenants, 10,000,000 distinct users, 181 days, 15.84 GiB on disk
940 M rows/s
sustained scan rate for avg(latency_ms), count() over the full table on 2 vCPU, 1.06 s end to end
7 / 122,127
granules read for one tenant on one day, 8 ms, after partition, statistics and primary-key pruning
5,567 → 7 ms
all-time daily totals from the base table versus from a 227 MiB AggregatingMergeTree view

All lab figures: ClickHouse 26.7.2.1, 2 vCPU / 7 GB / local NVMe-class disk, median of three runs with use_query_condition_cache = 0 unless stated. The table is larger than memory, so these are ClickHouse performance numbers from disk, not warm-cache numbers.

The scale at which why ClickHouse is so fast actually matters

A billion rows is a useful laboratory size because it is large enough to exceed memory and small enough to rebuild in eight minutes. It is not large by ClickHouse standards, and why ClickHouse is so fast at a billion rows is only interesting because the same answer holds at a quadrillion. The published production figures below are the reference points we keep in mind when we design a cluster; they are the vendors’ and operators’ own numbers, cited to source, not ours.

DeploymentPublished scaleSource
Cloudflare analyticsA single query covering 96 trillion events in one hour, and 1.61 quadrillion events over a full day, returning in under two seconds; open-source ClickHouse in production for close to ten years across more than 300 data centresClickHouse blog, Cloudflare
ClickHouse LogHouse (internal observability)1.59 × 10¹⁵ rows; 431 PiB uncompressed stored as 27 PiB on disk, a 16x compression ratio; peak ingest of 190 million rows per second and 80 GiB/s across about 5,800 inserts per second; 33 geoshards on three cloudsClickHouse blog, LogHouse
ClickBench (independent, reproducible)99,997,497-row web-analytics table, 43 queries covering full scans, filtered scans, index lookups and the main relational operators; cold and hot runs published per engineClickBench on GitHub
This article1,000,000,000 rows, 11 columns, 15.84 GiB compressed, 2 vCPU, every number reproducible from the appendixBelow

Table 1. Published scale references beside the lab, the context in which ClickHouse performance claims should be read. Cloudflare and LogHouse figures are quoted from the linked ClickHouse posts and have not been independently verified by us.

Two of those figures deserve a pause. A 16x compression ratio at 431 PiB is design choice 4 operating on real telemetry rather than on our synthetic data, and 190 million rows per second of ingest is design choice 7’s insert path at fleet scale. Everything measured below is the same machinery at one ten-thousandth of the size, which is exactly why ClickHouse performance characteristics measured on a small box transfer so reliably to a large one, and why ClickHouse is so fast on both.

The billion-row table

The table models a multi-tenant SaaS event stream: 500 tenants, 20,000 users each, a six-month window, 17 event types with a realistic skew towards page views, 40 countries, 4 device classes, 300 URL paths, HTTP status codes at a 95% success rate, and latency and payload columns with a long tail. Every value is a deterministic hash of the row number, so anyone can regenerate the identical table and check every ClickHouse performance figure below to the row; the generator is in the appendix.

sql · ClickHouse 26.7 · the table
CREATE TABLE lab.events
(
    tenant_id    UInt16,
    event_time   DateTime,
    event_date   Date MATERIALIZED toDate(event_time),
    user_id      UInt32,
    event_type   LowCardinality(String),
    country      LowCardinality(String),
    device       LowCardinality(String),
    url_path     LowCardinality(String),
    http_status  UInt16,
    latency_ms   UInt16,
    bytes_out    UInt32
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (tenant_id, event_time)
SETTINGS index_granularity = 8192;

-- 20 inserts of 50,000,000 rows from numbers_mt(), then OPTIMIZE TABLE ... FINAL
-- load: 1,000,000,000 rows in 498.6 s = 2.01 M rows/s on a single 2-thread insert stream

SELECT count() AS parts, sum(rows) AS rows, sum(marks) AS granules,
       formatReadableSize(sum(data_compressed_bytes))   AS compressed,
       formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed,
       formatReadableSize(sum(primary_key_size))        AS primary_index,
       formatReadableSize(sum(marks_bytes))             AS marks_on_disk
FROM system.parts
WHERE database = 'lab' AND table = 'events' AND active;

┌─parts─┬───────rows─┬─granules─┬─compressed─┬─uncompressed─┬─primary_index─┬─marks_on_disk─┐
│    40 │ 1000000000 │   122127 │ 15.84 GiB  │ 23.29 GiB    │ 570.72 KiB    │ 2.15 MiB      │
└───────┴────────────┴──────────┴────────────┴──────────────┴───────────────┴───────────────┘

Three facts from that output shape everything that follows about why ClickHouse is so fast on this table. The table is 15.84 GiB against 7 GB of RAM, so full scans are reading from disk, not from cache. The primary index for a billion rows is 570 KiB, one entry per 8,192-row granule, and it is loaded lazily on first use. And the default LZ4 codec achieved only 1.47x on this deliberately random data, which is a floor we will raise to 2.68x in design choice 4 and which real telemetry, as LogHouse’s 16x shows, leaves far behind.

Why ClickHouse is so fast on a billion rows: the two-phase read path with the measured effect of each design choice
Figure 1. Why ClickHouse is so fast, as a read path in two phases on the billion-row table. Choices 1 to 4 decide which bytes are read; choices 5 to 7 decide how fast each byte is processed. Every tile carries the measurement behind it.

Design choice 1: one compressed stream per column

Every Wide MergeTree part stores each column as its own compressed file with its own mark file. A query names columns and pays for those columns only, which is the first and simplest reason why ClickHouse is so fast on wide tables. On this table the latency_ms column is 1.77 GiB of the 15.84 GiB; a query that averages it reads that 1.77 GiB and nothing else, and count() reads no column data at all because row counts live in part metadata.

Full scan over 1,000,000,000 rows (max_threads = 2)Columns touchedCompressed bytes on diskMedianRows per second
count()none (part metadata)02 msn/a
avg(latency_ms), count()latency_ms1.77 GiB1,064 ms940 M
http_status, count() WHERE http_status >= 500 GROUP BY http_statushttp_status233 MiB1,021 ms979 M
sum(bytes_out)bytes_out2.75 GiB3,066 ms326 M
event_type, count() GROUP BY event_type (17 groups)event_type755 MiB3,127 ms320 M
uniqCombined(user_id) over 10,000,000 distinct usersuser_id3.41 GiB3,487 ms287 M
url_path, count() … ORDER BY count() DESC LIMIT 10url_path1.87 GiB3,481 ms287 M
tenant_id, count(), sum(bytes_out) GROUP BY tenant_id (500 groups)tenant_id, bytes_out2.76 GiB4,570 ms219 M

Table 2. ClickHouse performance on full-table scans, two cores, table larger than memory. Wall-clock tracks the compressed size of the columns named, not the size of the table, which is the whole argument for the layout.

Read the rows-per-second column against the bytes column and the ClickHouse performance pattern is plain: the engine is moving roughly 0.6 to 1.7 GB of compressed data per second off a virtual disk and through decompression on two cores, and the query time is that transfer time. A row store holding the same billion rows would have to move all 23 GiB for any of these queries. Our columnar versus row-based measurement has the PostgreSQL side of that comparison on a 20-million-row copy: 3.85 GB of heap read for a two-column aggregate that ClickHouse served from 45.5 MiB.

Design choice 2: a sparse primary index that reads 7 granules out of 122,127

A B-tree carries one entry per row. ClickHouse carries one entry per granule, the first sort-key tuple of every 8,192 rows, which is why 570 KiB indexes a billion rows. Because rows inside a part are physically sorted by the key, a predicate on the leading key columns becomes a binary search over that list and the result is a set of granule ranges. The mark file then seeks each named column directly to the compressed block that holds the first selected granule.

How the ClickHouse sparse primary index resolves a WHERE clause on a billion rows through primary.idx, mark files and compressed blocks
Figure 2. Why ClickHouse is so fast on sort-key predicates: primary.idx to marks to compressed blocks for one part of the billion-row table, with the three measured predicates below. Key values and offsets are illustrative; the structure and the measurements are exact.
sql · EXPLAIN indexes = 1 · one tenant, one day
EXPLAIN indexes = 1
SELECT count(), avg(latency_ms)
FROM lab.events
WHERE tenant_id = 42
  AND event_time BETWEEN toDateTime('2026-03-10 00:00:00') AND toDateTime('2026-03-10 23:59:59');

ReadFromMergeTree (lab.events)
  Parts: 5 | Granules: 7
  Indexes:
    Min-Max      Condition: true                 Parts: 40/40   Granules: 122087/122087
    Partition    Condition: true                 Parts: 40/40   Granules: 122087/122087
    Statistics   Keys: event_time, tenant_id     Parts: 5/40    Granules: 4873/122087
    PrimaryKey   Keys: tenant_id, event_time     Parts: 5/5     Granules: 7/4873
                 Search Algorithm: binary search
    Ranges: 5

-- median of 3 runs: 8 ms

Two layers cooperate here. Part-level column statistics, which the 26.7 build maintains automatically, discard 35 of the 40 parts because their event_time range cannot contain 10 March; the primary key then binary-searches the 4,873 granules of the five survivors down to 7. Eight milliseconds against a billion rows, and the table was never in memory. If one measurement explains why ClickHouse is so fast for tenant-scoped analytics, it is this one.

Predicate on the billion-row tableIndex pathGranules readMedian
tenant_id = 42 AND event_time within one dayStatistics 40 → 5 parts, PrimaryKey binary search7 of 122,1278 ms
tenant_id = 42 (all 2,000,697 rows of the tenant)PrimaryKey binary search on the first key column291 of 122,12731 ms
tenant_id = 42 AND user_id = 4200042 (106 rows)PrimaryKey on tenant_id, PREWHERE on user_id inside it291 of 122,12728 ms
event_time within one day, no tenant (15.8 M rows)Statistics 40 → 5 parts; second key column, generic search1,929 of 122,12796 ms
user_id = 4200042 alone (106 rows)No usable index; PREWHERE scan of the 3.41 GiB column122,087 of 122,1272,875 ms

Table 3. Five predicates, one table, and the ClickHouse performance range they span. The same 106 rows cost 28 ms or 2,875 ms depending on whether the query also names the first sort-key column. Nothing else changed.

The last two rows are the most instructive thing on this page for anyone designing a schema. A predicate that follows the sort-key prefix is resolved by the index; a predicate that does not is a full scan of that column, however selective it is. This is the single largest lever in ClickHouse performance engineering, and it is set at CREATE TABLE time, which is why ClickHouse is so fast for teams that chose their ORDER BY carefully and merely adequate for teams that did not.

Reading the plan

If the PrimaryKey line of EXPLAIN indexes = 1 shows the same granule count before and after, the query is not using your ORDER BY. Fix the key, add a projection with a different order, or accept the scan. No server setting compensates for it.

Design choice 3: partition pruning, skip indexes and the query condition cache

Above the primary index sit coarser filters, each cheap, each visible in the same plan output that drives every ClickHouse performance review. The partition key’s min/max is kept per part, so a month predicate on this table touches 5 to 7 of the 40 parts before any granule-level work. Automatic column statistics, visible as the Statistics line in the plans above, do the same for columns that are not the partition key. Skip indexes extend the idea to columns outside the sort key altogether: a min/max pair, a set of values or a Bloom filter per block of granules, consulted to prove that a block cannot contain a match.

The 26.x line adds a further layer that surprised us during this lab. The query condition cache remembers, per predicate and per granule, which granules matched the last time the predicate ran. On the user_id scan from Table 3, the one with no index help at all, the second execution went from 2,020 ms to 19 ms with no change to the query, the table or the data.

sql · the query condition cache on the worst-case predicate
SELECT count(), sum(bytes_out) FROM lab.events WHERE user_id = 4200042
SETTINGS use_query_condition_cache = 1;
-- run 1: 2,020 ms   run 2: 19 ms   run 3: 23 ms

SELECT count(), sum(bytes_out) FROM lab.events WHERE user_id = 4200042
SETTINGS use_query_condition_cache = 0;
-- run 1: 1,946 ms   run 2: 1,975 ms

SELECT count() AS entries, formatReadableSize(sum(entry_size)) AS size
FROM system.query_condition_cache;

A 100x improvement on a repeated dashboard filter costs nothing and is on by default in this build, which is why every headline number in this article was measured with it switched off. It is also why a naive benchmark that runs each query three times and reports the best run will overstate ClickHouse performance on filtered queries by two orders of magnitude. Report the first run, or disable the cache, and say which; every ClickHouse performance figure in this article does.

Skip index typeSummary stored per blockPays whenWasted when
minmaxmin and maxThe column correlates with the sort order (timestamps, monotonic IDs)Values spread through every block
set(N)up to N distinct valuesA low-cardinality column clustered in runsPer-block cardinality exceeds N
bloom_filter(p)Bloom filter with false-positive rate pEquality and IN on high-cardinality values read from slow or cold storageRange predicates; warm narrow columns that PREWHERE already handles
text (current releases; supersedes tokenbf_v1 and ngrambf_v1)Inverted token indexhasToken, LIKE and full-text on log linesVery short strings; ingest-heavy tables without tuning

Table 4. Skip-index types on ClickHouse 26.x and their ClickHouse performance profile. On our 20-million-row sibling table a Bloom filter pruned 92% of granules and moved wall-clock by nothing, because PREWHERE had already done the work on a warm column; on the billion-row table, where the user_id column is 3.41 GiB and cold, it is precisely the tool for the 2,875 ms row of Table 3.

Design choice 4: per-column codecs, measured on 99.5 million rows

A column stream holds values of one type, often sorted or slowly varying, and ClickHouse lets each column declare its own codec chain: a shape-aware codec (Delta, DoubleDelta, Gorilla, T64, GCD, FPC) followed by a general-purpose one (LZ4 by default in open-source ClickHouse, ZSTD by default in ClickHouse Cloud). We copied the first 99.5 million rows of the events table into a second table with codecs chosen per column and compared, because compression is the least-discussed part of why ClickHouse is so fast and often the cheapest to improve.

sql · per-column codecs on the same 99,532,800 rows
CREATE TABLE lab.events_codec
(
    tenant_id    UInt16                 CODEC(Delta(2), ZSTD(1)),
    event_time   DateTime               CODEC(Delta(4), ZSTD(1)),
    event_date   Date MATERIALIZED toDate(event_time),
    user_id      UInt32                 CODEC(ZSTD(1)),
    event_type   LowCardinality(String) CODEC(ZSTD(1)),
    country      LowCardinality(String) CODEC(ZSTD(1)),
    device       LowCardinality(String) CODEC(ZSTD(1)),
    url_path     LowCardinality(String) CODEC(ZSTD(1)),
    http_status  UInt16                 CODEC(T64, ZSTD(1)),
    latency_ms   UInt16                 CODEC(T64, ZSTD(1)),
    bytes_out    UInt32                 CODEC(T64, ZSTD(1))
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (tenant_id, event_time);

┌─table────────┬─compressed─┬─uncompressed─┬─ratio─┐
│ events (LZ4) │ ~1.58 GiB  │ 2.32 GiB     │  1.47 │   -- pro-rated from the full table
│ events_codec │ 887.10 MiB │ 2.32 GiB     │  2.68 │
└──────────────┴────────────┴──────────────┴───────┘
ColumnLZ4 default (full table, ratio)Tuned codec (99.5 M slice, ratio)What changed
tenant_id8.61 MiB (221x)206 KiB (945x)Delta on the first sort-key column turns a sorted UInt16 into zeros
event_date9.00 MiB (212x)909 KiB (214x)Already near its floor
http_status234 MiB (8.2x)16.7 MiB (11.4x)T64 packs a 16-bit column that holds four distinct values
event_time3.60 GiB (1.0x)90.6 MiB (4.2x)Delta(4) on timestamps that are monotonic within each tenant; LZ4 alone saw noise
bytes_out2.75 GiB (1.4x)165 MiB (2.3x)T64 drops the unused high bits of a UInt32
user_id3.41 GiB (1.1x)252 MiB (1.5x)Hash-random within a 20,000 range: little for any codec to find

Table 5. Codec effect per column, the ClickHouse performance lever that costs nothing at query time. event_time is the lesson: a timestamp column that LZ4 could not compress at all shrank 4.2x once told it was a sequence of deltas.

The codec table was also faster to query, not slower, a ClickHouse performance result that runs against the usual intuition about ZSTD. The same avg(latency_ms), count() ran in 155 ms on the ZSTD table against 186 ms on the LZ4 slice, and the 17-way GROUP BY in 358 ms against 413 ms.

On the 20-million-row table in our sibling article, which fitted in page cache, LZ4 won by 20% because the scan was CPU-bound; here the table does not fit and the scan is bound by bytes moved, so the smaller table wins. Which regime a production node is in is a measurement, not an opinion, and system.parts_columns on your own data is where the answer lives.

Design choice 5: vectorised execution, and what it costs to lose it

Once bytes are in memory ClickHouse never reconstructs rows, and this is where why ClickHouse is so fast stops being about I/O. Every processor consumes and produces a chunk of column arrays holding up to max_block_size rows, 65,409 by default, and runs one tight typed loop per chunk with SIMD kernels selected at runtime. The interpretive cost of deciding what to execute is paid once per chunk rather than once per row. The cheapest way to price that design, and to see how much of why ClickHouse is so fast lives in it, is to shrink the chunk on a billion rows.

max_block_sizeRows per processor callevent_type, count(), avg(latency_ms) GROUP BY event_type over 1 B rowsRelative
65409 (default)65,4094,330 ms1.0x
81928,1925,680 ms1.3x
10241,02415,475 ms3.6x

Table 6. Why ClickHouse is so fast per byte: same query, same data, same two threads, same bytes read. At 1,024 rows per call the per-call overhead is 11 extra seconds on a billion rows. At the default it is amortised to nothing, which is a large part of why ClickHouse is so fast per byte.

sql · EXPLAIN PIPELINE · the shape on two threads
EXPLAIN PIPELINE
SELECT event_type, count(), avg(latency_ms)
FROM lab.events
GROUP BY event_type
SETTINGS max_threads = 2;

(Expression)  ExpressionTransform × 2
(Aggregating) Resize 2 → 2
              AggregatingTransform × 2
(Expression)  ExpressionTransform × 2
(ReadFromMergeTree)
              MergeTreeSelect(pool: ReadPool, algorithm: Thread) × 2 0 → 1

PREWHERE is the vectorised engine’s I/O-side twin and a ClickHouse performance feature most teams inherit without knowing it. When a query filters on cheap columns and selects expensive ones, the optimiser reads the filter columns first, evaluates the predicate, and fetches the remaining columns only for granules that survived. We asked for every column of every row that was a purchase, returned HTTP 500 and took more than 1.4 seconds: 2,924 rows out of a billion.

sql · optimize_move_to_prewhere on and off, SELECT * over 1 B rows
SELECT * FROM lab.events
WHERE http_status = 500 AND event_type = 'purchase' AND latency_ms > 1400
SETTINGS max_threads = 2, optimize_move_to_prewhere = 1;   -- median 9,613 ms

SELECT * FROM lab.events
WHERE http_status = 500 AND event_type = 'purchase' AND latency_ms > 1400
SETTINGS max_threads = 2, optimize_move_to_prewhere = 0;   -- median 15,813 ms

Six seconds of ClickHouse performance from a default setting most teams have never heard of, on a query that reads 233 MiB of http_status to avoid reading 15 GiB of everything else. On a node with more cores and faster storage the absolute times shrink; the ratio does not.

Design choice 6: parallelism over mark ranges, from two cores to three clouds

ClickHouse parallelises at three multiplicative levels, and the unit of work at every level is the same object: a range of marks inside a part. A thread, a shard and a replica each take mark ranges; none of them introduces a new kind of coordination, which is why ClickHouse is so fast to scale out as well as up. On our two cores the first level is the only one we can show, and it is close to ideal; it is also why ClickHouse is so fast on a cluster for the same reason it is fast on one node.

max_threadsGROUP BY event_type with avg over 1 B rowsScaling
18,640 ms1.00x
24,457 ms1.94x

Table 7. ClickHouse performance scaling across threads on identical mark ranges. Each thread runs a private pipeline and a private aggregation hash table; the two are merged once at the end.

01
Level 1: threads within a node
A SELECT spreads its mark ranges over max_threads (auto = physical cores). Each thread owns its hash table; the merge is bucket-parallel once the table converts to two-level. Measured: 1.94x on two cores, which is what a 64-core node also delivers per core until storage saturates.
02
Level 2: shards
A Distributed table fans the query to every shard, which executes it locally and returns partial aggregate states rather than rows. distributed_aggregation_memory_efficient streams the merge so the initiator stays bounded.
03
Level 3: parallel replicas
Replicas of one shard cooperate on a single query, the initiator handing out mark ranges dynamically. It turns a standby into compute. Opt-in in open-source ClickHouse (enable_parallel_replicas = 0 is the 26.7 default we read from system.settings) and the machinery behind LogHouse’s 33 geoshards.

The ClickHouse performance blast radius of turning any level up is memory: one aggregation hash table per thread, per replica. Raise max_threads or enable parallel replicas together with max_memory_usage and max_concurrent_queries, stage on one replica, and read system.query_log.memory_usage at p99 before rolling out.

Design choice 7: merge-time precomputation, 5,567 ms to 7 ms

MergeTree is LSM-shaped. Inserts create immutable sorted parts; background merges combine them, re-sort them, rebuild marks and indexes, apply TTL and recompress. That gives the engine a place to do work between ingest and query that a row store does not have, and incremental materialized views use it: every inserted block is run through the view’s SELECT and the resulting aggregate states are written to a target table whose merges fold states with the same key together.

Merge-time precomputation on a billion rows: insert path, aggregating materialized view, background merges and the measured dashboard speedup
Figure 3. Why ClickHouse is so fast on dashboards: insert to result through a daily aggregating view on the billion-row table, with the measured cost of tiny inserts and the cost of choosing the wrong view granularity.
sql · a daily per-tenant view over the billion-row table
CREATE TABLE lab.events_daily
(
    tenant_id UInt16,
    day       Date,
    events    AggregateFunction(count),
    errors    AggregateFunction(countIf, UInt8),
    users     AggregateFunction(uniqCombined(12), UInt32),
    bytes_out AggregateFunction(sum, UInt64)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(day)
ORDER BY (tenant_id, day);

CREATE MATERIALIZED VIEW lab.mv_events_daily TO lab.events_daily AS
SELECT tenant_id, event_date AS day,
       countState()                       AS events,
       countIfState(http_status >= 500)   AS errors,
       uniqCombinedState(12)(user_id)     AS users,
       sumState(toUInt64(bytes_out))      AS bytes_out
FROM lab.events
GROUP BY tenant_id, day;

-- one-time backfill of the existing billion rows, month by month: 26.5 s
-- target: 90,500 rows, 227.36 MiB (the source table is 15.84 GiB)

-- events, users and errors per tenant for one quarter
--   base table: 502,336,000 rows, 61,324 granules      median 4,455 ms
--   view:            45,500 rows,      9 granules      median   271 ms
-- all-time daily totals
--   base table: 1,000,000,000 rows                     median 5,567 ms
--   view:              90,500 rows                     median     7 ms
-- results compared row for row: identical

The view is 1.4% of the table on disk and answers the two dashboard questions 16x and 795x faster respectively, including a distinct-user count that would otherwise hash half a billion values. Two disciplines keep this ClickHouse performance pattern honest in production. The reading query’s GROUP BY must match the target’s ORDER BY, and a view sees only new inserts, so existing data needs the one-time backfill shown and any later correction of source rows needs a matching correction in the target.

Granularity is the design decision, and the difference between a view that explains why ClickHouse is so fast on a dashboard and one that merely costs disk. Our first attempt was an hourly view keyed by tenant, hour and event type. It grew to 21.7 million rows and 2.12 GiB, carried a quantile state per row, and answered the quarterly question in 4,078 ms against 4,455 ms from the base table, a saving not worth its storage.

Collapsing to tenant and day, and dropping the quantile in favour of the count, error and distinct-user states the dashboard actually displays, produced the 271 ms result. A materialized view is a query plan frozen at insert time; it has to be the plan the dashboard needs, and when it is, it is why ClickHouse is so fast on dashboards that summarise billions of rows.

The merge is also where ingest design meets ClickHouse performance, and where why ClickHouse is so fast to read becomes a question of how it is written. One thousand single-row inserts took 2.3 s and produced one thousand parts, which background merges reduced to six, the ClickHouse performance failure mode that fills support queues; one insert of 10,000,000 rows took 0.17 s, a rate of 57 million rows per second into a sorted part.

LogHouse’s 190 million rows per second across 5,800 inserts per second is the same arithmetic at fleet size: about 33,000 rows per insert. Batch at the client or set async_insert = 1 so the server batches for you; parts_to_throw_insert (3,000 by default) is the guard rail that eventually rejects the alternative.

Where ClickHouse is not fast, on the same billion rows

Understanding why ClickHouse is so fast tells you exactly where it is not, and our lab produced three examples without looking for them. A point filter on a column outside the sort key is a full column scan: 2,875 ms for 106 rows in Table 3, against 28 ms when the query also names the tenant.

The second example is a ClickHouse performance edge inside the aggregation engine. A GROUP BY on two LowCardinality columns at once, country and device, took 25 seconds where the single-key GROUP BY took 3, because the aggregation falls off the fixed-width fast path onto a serialised composite key; the fix is a precomputed view or a single derived key, and it is the kind of thing a profile finds in a minute and a benchmark table never shows.

The third example is aggregation cost rather than I/O. A 95th-percentile latency across 160 country-device groups took 27 seconds because a billion values were sampled into 160 quantile states; that query belongs in a materialized view, not on a dashboard.

The structural limits of ClickHouse performance are the ones we measured on the 20-million-row sibling table in the row-store comparison: a point lookup by primary key at 5 to 9 ms against 0.1 ms in PostgreSQL, and a single-row UPDATE as an 8-second mutation against 0.65 ms. Hash joins build the right-hand table in memory per query, so a billion-row fact-to-fact join without dictionaries, denormalisation or grace_hash exhausts max_memory_usage. If a workload is mostly point reads and row-level writes, no ClickHouse performance tuning rescues it; it belongs on a row store with CDC into ClickHouse for the analytical half, and MinervaDB’s PostgreSQL and MySQL practices exist for exactly that split.

Measuring why ClickHouse is so fast, or is not, on your own cluster

Every claim on this page maps to a column in system.query_log. This is the first query we run in a ChistaDATA ClickHouse performance engagement, per query shape: how many bytes Phase 1 let through and how much CPU Phase 2 spent on them.

sql · the first query of every performance engagement
SELECT
    normalizedQueryHash(query)                                   AS query_hash,
    count()                                                      AS executions,
    quantile(0.95)(query_duration_ms)                            AS p95_ms,
    formatReadableSize(avg(read_bytes))                          AS avg_read,
    avg(read_rows)                                               AS avg_read_rows,
    avg(ProfileEvents['SelectedMarks'])                          AS avg_marks,
    avg(ProfileEvents['SelectedParts'])                          AS avg_parts,
    avg(ProfileEvents['OSCPUVirtualTimeMicroseconds']) / 1000    AS avg_cpu_ms,
    formatReadableSize(max(memory_usage))                        AS max_memory,
    any(query)                                                   AS sample_query
FROM system.query_log
WHERE type = 'QueryFinish'
  AND event_time >= now() - INTERVAL 24 HOUR
  AND query_kind = 'Select'
GROUP BY query_hash
ORDER BY p95_ms DESC
LIMIT 25;
SignalPoints atDesign choice to revisit
avg_marks close to the table’s total marksPrimary index not used2: reorder ORDER BY, or 7: a projection
High read_bytes with few read_rowsWide columns, SELECT *, or a poor codec1 and 4
Second run much faster than the firstQuery condition cache, page cache or query cache3: report first-run numbers
avg_cpu_ms far above p95_msGood parallelism, CPU-bound7: precompute, or 5: block size and JIT
p95_ms far above avg_cpu_msI/O wait, merge pressure or lock waitStorage tier, system.merges, choice 3 on cold data
max_memory near max_memory_usageHash table or join spill risk6: thread and replica settings, join_algorithm

Table 8. Reading query-log output back to the seven design choices behind why ClickHouse is so fast.

SettingDefault read from system.settings on 26.7Effect on speed
max_threadsauto (physical cores)Level-1 parallelism; 1.94x on two cores here
max_block_size65409Rows per chunk; Table 6 shows the cost of shrinking it
optimize_move_to_prewhere1Filter on cheap columns before reading expensive ones; 6 s on a billion rows here
use_query_condition_cache1Granule-level memo of predicate results; 100x on repeated filters
use_skip_indexes1Consult skip indexes; set 0 to measure their real contribution
compile_expressions1LLVM JIT for expression chains
optimize_read_in_order1Stream sorted parts through ORDER BY without a sort
join_algorithmdirect, parallel_hash, hashJoin strategy order
enable_parallel_replicas0Level-3 parallelism, opt-in in open source
use_query_cache0Whole-result caching, opt-in; never for queries containing now()

Table 9. ClickHouse performance settings and the defaults we read from system.settings on the 26.7 build. Session settings apply on the next query; server configuration needs a restart.

Reproduce the billion rows

The generator below produces the identical table on any ClickHouse 26.x, including chDB, so every claim here about why ClickHouse is so fast can be checked rather than believed. On two vCPU it ran at 2.01 million rows per second and finished in 498.6 s; OPTIMIZE FINAL took a further 156 s. Budget 16 GiB of disk plus one partition of headroom for the merge. Test on a copy of your own data before drawing conclusions for production, and keep a tested restore path before touching any schema that serves traffic.

sql · deterministic generator for 1,000,000,000 rows (run in 20 chunks of 50 M)
INSERT INTO lab.events
SELECT
    toUInt16(1 + intHash32(number) % 500)                                   AS tenant_id,
    toDateTime('2026-01-01 00:00:00') + toIntervalSecond(intDiv(number, 64)) AS event_time,
    toUInt32(tenant_id * 100000 + intHash64(number) % 20000)                AS user_id,
    ['page_view','page_view','page_view','page_view','page_view','page_view','click','click',
     'api_call','api_call','search','login','purchase','error','logout','signup','export']
        [1 + intHash32(number * 7) % 17]                                     AS event_type,
    ['US','US','US','IN','IN','DE','GB','BR','FR','JP','CA','AU','SG','NL','ES','IT','MX','KR','ID','AE',
     'SE','PL','TR','ZA','AR','CH','BE','TH','MY','PH','VN','NG','EG','SA','IL','IE','AT','DK','NO','FI']
        [1 + intHash32(number * 11) % 40]                                    AS country,
    ['ios','android','web','web','web','desktop'][1 + intHash32(number * 13) % 6] AS device,
    concat('/api/v1/', ['orders','users','catalog','cart','search','checkout','profile','reports','events','billing']
        [1 + intHash32(number * 17) % 10], '/', toString(intHash32(number * 19) % 30)) AS url_path,
    multiIf(intHash32(number * 23) % 1000 < 950, 200,
            intHash32(number * 23) % 1000 < 985, 404,
            intHash32(number * 23) % 1000 < 995, 500, 302)                   AS http_status,
    toUInt16(8 + intHash32(number * 29) % 240 + if(intHash32(number * 31) % 40 = 0, 1200, 0))     AS latency_ms,
    toUInt32(180 + intHash32(number * 37) % 4000 + if(intHash32(number * 41) % 25 = 0, 60000, 0)) AS bytes_out
FROM numbers_mt(0, 50000000)          -- then 50000000, 100000000, ... up to 950000000
SETTINGS max_insert_threads = 2, max_threads = 2;

OPTIMIZE TABLE lab.events FINAL;

-- the toggles that isolate each design choice
SELECT ... SETTINGS use_query_condition_cache = 0;   -- always, for first-run numbers
SELECT ... SETTINGS use_skip_indexes = 0;            -- choice 3
SELECT ... SETTINGS max_block_size = 1024;           -- choice 5
SELECT ... SETTINGS optimize_move_to_prewhere = 0;   -- choice 5
SELECT ... SETTINGS max_threads = 1;                 -- choice 6

Knowing why ClickHouse is so fast is the start; keeping it fast at a quadrillion rows is engineering

ChistaDATA runs 24×7×365 consultative support, ClickHouse performance engineering, platform engineering and managed services on 100% open-source ClickHouse, with a 15-minute S1 response and no vendor lock-in. If your p95 is not where this page says it should be, we find out which of the seven choices your tables are missing, with the same system tables shown here: ClickHouse consulting, ClickHouse support, ClickHouse managed services and ClickHouse migration.

Further reading

ChistaDATA Inc. is not affiliated with ClickHouse, Inc. ClickHouse® is a registered trademark of ClickHouse, Inc. Third-party scale figures are quoted from the linked publications. Lab behaviour was verified on open-source ClickHouse 26.7; test every change in staging before applying it to production and maintain a tested disaster-recovery posture.

About ChistaDATA Inc. 259 Articles
ChistaDATA is a full-stack ClickHouse infrastructure operations company delivering consulting, 24×7 enterprise support, and managed services, with core expertise in performance engineering, scalability, and data SRE. Headquartered in California, our consulting and support engineering teams operate from San Francisco, Vancouver, London, Germany, Russia, Ukraine, Australia, Singapore, and India, providing follow-the-sun, enterprise-class consultative support around the clock. We work closely with more than 200 customers globally, including some of the largest planet-scale internet properties, financial-services institutions, consumer brands, and industrial IoT programmes.