Why ClickHouse Is So Fast: 7 Design Choices Behind Its Speed
Why ClickHouse is so fast is not a matter of one trick, but because every layer of the read path — column files, sparse primary index, skip indexes, per-column codecs, vectorized operators, multi-level parallelism and merge-time precomputation — was engineered to touch the fewest bytes and spend the fewest CPU cycles per row. This page walks through each mechanism with the DDL, EXPLAIN output and system.* tables you use to verify it on your own cluster.
index_granularity)GROUP BY (VLDB 2024 paper)Why ClickHouse is so fast: the question a practitioner actually asks
A team migrating an OLAP workload off Redshift, BigQuery, Elasticsearch or a row store sees ClickHouse return a GROUP BY over a few billion rows in sub-second time and asks the obvious question: what is it doing that our previous engine was not? The short answer to why ClickHouse is so fast is that it does dramatically less work per query. The long answer — and the useful one, because it tells you how to design tables that keep it fast — is below.
This article follows the query’s own path: first the I/O phase (which parts, granules and columns are read, and how many bytes come off disk), then the CPU phase (how those bytes are turned into a result). Each design choice is tied to the catalog table or EXPLAIN variant that lets you measure it, because a claim you cannot measure is not an engineering claim.
SharedMergeTree shares the read-path mechanics but differs in storage layout and defaults (for example ZSTD rather than LZ4 as the default codec). Where behaviour is version-sensitive it is pinned inline.Query latency has two phases — ClickHouse attacks both
Take the canonical analytical query shape:
SELECT
customer_id,
sum(amount) AS total_amount,
quantile(0.95)(latency_ms) AS p95_latency
FROM events
WHERE event_date BETWEEN '2026-08-01' AND '2026-08-07'
AND region = 'eu-west-1'
GROUP BY customer_id
ORDER BY total_amount DESC
LIMIT 100;It touches three or four of perhaps several hundred columns, filters on a range predicate, aggregates, sorts, and truncates. Wall-clock time decomposes into Phase 1: locate and read the bytes (partition pruning → primary-index range selection → skip-index filtering → column-file reads → decompression) and Phase 2: process the bytes (vectorized filter, hash aggregation, partial sort, merge across threads and nodes).
In most warehouses Phase 1 dominates for cold, wide scans and Phase 2 dominates for hot, high-cardinality aggregations. Understanding why ClickHouse is so fast means looking at both: it is unusual in having been engineered aggressively at both. Design choices 1–4 below minimise Phase 1; choices 5–7 minimise Phase 2.
EXPLAIN variant that exposes it. Bytes eliminated in Phase 1 never cost CPU in Phase 2.Columnar storage: read only the columns the query names
Why ClickHouse is so fast · reason 1 of 7
Every MergeTree part stores each column in its own compressed stream. In Wide parts that is a <column>.bin file plus a <column>.cmrk2 (or .mrk2) mark file per column; in Compact parts (small parts, below min_bytes_for_wide_part, default 10 MiB since 22.x) all columns share a single data.bin but remain separately addressable via marks. A query that references 4 of 300 columns opens 4 streams. The other 296 are never touched — no I/O, no decompression, no cache pollution.
The consequence for a row store is not subtle. PostgreSQL, MySQL/InnoDB and SQL Server keep a row’s columns together in a page; reading one column means reading the page that holds all of them. On a 300-column, 100-billion-row table the row store reads ~75× more bytes than ClickHouse for the same 4-column aggregate before any index or compression effect is counted. Redshift and Snowflake are also columnar, so this particular advantage is against OLTP engines and Elasticsearch/Lucene doc-values rather than against other warehouses — the differences against those come from choices 2–7.
Two properties make ClickHouse’s column layout more efficient than “columnar” alone implies: values in a column stream are fixed-width for numeric and date types with no per-value headers, and Nullable, LowCardinality, Array and Tuple types are decomposed into separate sub-streams (null-map, dictionary, offsets, elements) so that even a nested column is read only to the depth the query needs.
SELECT * in production paths and avoid storing large JSON blobs as String when queries only need a few keys — since 25.3 the native JSON type stores each dynamic path as its own sub-column and prunes exactly like a regular column.<partition>_<min_block>_<max_block>_<level>; file names shown are the compressed-marks (.cmrk2) and compressed-primary-index (primary.cidx) variants produced when compress_marks and compress_primary_key are enabled (the default in current releases).Sparse primary index: skip 99% of rows before reading a byte of data
Why ClickHouse is so fast · reason 2 of 7
marks in system.parts.A B-tree indexes every row; ClickHouse is so fast on range scans partly because its primary index instead stores the ORDER BY key for the first row of every granule — one entry per 8,192 rows by default (index_granularity; index_granularity_bytes = 10 MiB makes it adaptive for wide rows).
Because rows inside a part are physically sorted by that key, a predicate on the leading key columns turns into a binary search over the mark list, and the result is a set of granule ranges — not row pointers. The table’s other column files are then read only for those ranges, using the mark files to seek straight to the right compressed block. This granule-level pruning is the single largest contributor to why ClickHouse is so fast on range predicates.
The index lives in RAM (it is loaded on part activation; system.parts.primary_key_bytes_in_memory shows the footprint) and is trivially cheap to maintain because it is rewritten only when a part is written or merged, never updated in place. This is the reason ClickHouse can sustain millions of rows per second of ingest while keeping an index that filters reads — an insert only ever appends a new sorted part.
DDL that makes the index useful
CREATE TABLE events
(
event_date Date,
region LowCardinality(String),
customer_id UInt64,
event_ts DateTime64(3),
amount Decimal(18, 4),
latency_ms UInt32,
payload JSON
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (region, event_date, customer_id)
SETTINGS
index_granularity = 8192,
index_granularity_bytes = 10485760;Key order matters: put the lowest-cardinality, most-filtered column first (region), then the range column most queries constrain (event_date), then a higher-cardinality tiebreaker. The WHERE in Figure 1 uses region and event_date — a prefix of the key — so both predicates prune granules. A predicate on customer_id alone would not use the index efficiently (ClickHouse falls back to a generic exclusion search, which is weak on a non-prefix column). That is what skip indexes and projections are for.
Verify it: EXPLAIN indexes = 1
EXPLAIN indexes = 1
SELECT customer_id, sum(amount)
FROM events
WHERE event_date BETWEEN '2026-08-01' AND '2026-08-07'
AND region = 'eu-west-1'
GROUP BY customer_id;The output contains an Indexes: block for the ReadFromMergeTree step with, in order, MinMax (partition min/max), Partition (partition-key expression), PrimaryKey, and one entry per Skip index, each showing Parts: x/y and Granules: x/y before and after that index was applied. If PrimaryKey shows the same granule count before and after, the query is not using your ORDER BY and the table design needs to change, not the hardware.
Data-skipping indexes and partition pruning: filter on columns outside the sort key
Why ClickHouse is so fast · reason 3 of 7
Skip indexes are not row-level indexes. Each one stores a small summary per block of GRANULARITY n granules — a min/max pair, a set of distinct values, or a Bloom filter — and at query time ClickHouse uses that summary to prove that a block cannot contain matching rows and drops it.
They never produce false negatives; they simply cannot prune when the summary is inconclusive, which is why the value of a skip index is entirely a function of how the column’s values are physically clustered along the sort order. Used well, they extend why ClickHouse is so fast from the sort key to the next two or three most-filtered columns.
| Type | Summary stored | Best for | Weak when |
|---|---|---|---|
minmax | min & max per block | Columns loosely correlated with the sort key (timestamps, monotonic IDs) | Values uniformly spread through every block |
set(N) | up to N distinct values per block | Low-cardinality columns clustered in runs | Cardinality per block exceeds N (index becomes “unknown”) |
bloom_filter(p) | Bloom filter with false-positive rate p | Equality / IN on high-cardinality values (IDs, UUIDs) | Range predicates; values present in most blocks |
text (current releases; supersedes the deprecated tokenbf_v1/ngrambf_v1) | Inverted index over tokens | hasToken, LIKE, full-text lookups on log lines | Very short strings; extremely high ingest rates without tuning |
Partition pruning sits one level above: the PARTITION BY expression’s min/max is stored per part (minmax_<col>.idx), so a predicate on the partition key eliminates whole parts before any granule-level work. Monthly partitions on event_date plus event_date in the sort key is the common pattern; do not partition finer than needed — thousands of small parts hurt merges and the “too many parts” guard far more than pruning helps.
minmax and partition pruning. The difference is that ClickHouse exposes the mechanism as explicit DDL with set, bloom_filter and text variants, keeps sort order deterministic at merge time, and lets you verify pruning per index in EXPLAIN. That is an engineering-control difference, not a magic-speed difference — and both vendors document their own approaches accurately.Add a skip index and materialize it for existing parts
-- Bloom filter for point lookups on a high-cardinality column
-- that is NOT a prefix of ORDER BY.
ALTER TABLE events
ADD INDEX idx_customer_bf customer_id
TYPE bloom_filter(0.01)
GRANULARITY 4;
-- Builds the index for parts that already exist. This is a
-- mutation: it rewrites index files, not data. Test on staging;
-- watch system.mutations for is_done = 1 before relying on it.
ALTER TABLE events MATERIALIZE INDEX idx_customer_bf;Verify it: size and pruning
SELECT
name,
type_full,
granularity,
formatReadableSize(data_compressed_bytes) AS on_disk,
marks
FROM system.data_skipping_indices
WHERE database = currentDatabase()
AND table = 'events';
-- Then confirm the Skip entry in:
EXPLAIN indexes = 1
SELECT count() FROM events WHERE customer_id = 8812734;If Granules after the Skip line are not materially lower than before it, the index is costing write amplification for nothing — drop it. A useful production rule: a skip index that prunes less than ~70% of granules for its target queries is usually the wrong tool; a projection with a different ORDER BY (choice 7) is the right one.
Related reading: how data-skipping indexes are implemented, parts, partitions and primary-index design, and the official skip-index guide.
Per-column compression codecs: fewer bytes off disk, decoded at memory speed
Why ClickHouse is so fast · reason 4 of 7
Codec chains per column
CREATE TABLE metrics
(
ts DateTime CODEC(DoubleDelta, ZSTD(1)),
series_id UInt64 CODEC(Delta(8), ZSTD(1)),
host LowCardinality(String),
value Float64 CODEC(Gorilla, ZSTD(1)),
counter UInt64 CODEC(T64, LZ4),
raw_log String CODEC(ZSTD(3))
)
ENGINE = MergeTree
ORDER BY (series_id, ts);Measure the ratio per column
SELECT
column,
any(compression_codec) AS codec,
formatReadableSize(sum(column_data_compressed_bytes)) AS compressed,
formatReadableSize(sum(column_data_uncompressed_bytes)) AS uncompressed,
round(sum(column_data_uncompressed_bytes)
/ sum(column_data_compressed_bytes), 2) AS ratio
FROM system.parts_columns
WHERE active
AND database = currentDatabase()
AND table = 'metrics'
GROUP BY column
ORDER BY sum(column_data_compressed_bytes) DESC;Run this before and after a codec change on a copy of a real part. Never guess a codec: Gorilla on a slowly-varying gauge and on a random-walk float produce very different ratios, and T64 is only worth it on integers whose actual range is far below the declared width.
Because a column stream holds values of one type, often sorted or slowly varying, it compresses far better than a row page — and ClickHouse is so fast at reading it back because decompression runs at memory speed. ClickHouse compounds this with codec chains: a specialised codec that exploits data shape (Delta, DoubleDelta, Gorilla, T64, GCD, FPC, and in current releases ALP for floats — check the CREATE TABLE codec list for your exact version) followed by a general-purpose codec (LZ4 default in OSS, ZSTD default in Cloud, LZ4HC, and hardware-accelerated ZSTD_QAT/DEFLATE_QPL where the platform supports them).
The performance effect is twofold. First, fewer bytes are read: a 10× ratio turns a 1 TB scan into a 100 GB scan, and on object storage that is the difference between seconds and minutes. Second, decompression is faster than the I/O it replaces. LZ4 decodes at several GB/s per core; ZSTD level 1 is slower but still usually faster than NVMe on a per-core basis when the read is spread across max_threads. The break-even moves with hardware, which is why the choice is per-column and measurable, not global. Compression is the least-discussed part of why ClickHouse is so fast, and often the cheapest to improve.
Two structural details matter more than the codec list. The mark file records both the compressed offset and the offset inside the decompressed block, so a granule read seeks directly to one compressed block (default up to 1 MiB, max_compress_block_size) rather than decompressing a whole column. And LowCardinality(String) is not a codec but a dictionary-encoded column type: it stores an index stream plus a dictionary, so GROUP BY region aggregates over small integers, not strings — a Phase-2 win that comes from a Phase-1 storage decision.
ALTER TABLE … MODIFY COLUMN rewrites the column in every part through a mutation. On a multi-TB table that is hours of background I/O and temporary double storage. Stage it, verify with system.mutations, and keep a tested restore path.Deeper treatment: compression algorithms and codecs in ClickHouse.
Vectorized execution: operators work on column chunks, not rows
Why ClickHouse is so fast · reason 5 of 7
Once bytes are in memory, ClickHouse never reconstructs rows. The execution engine follows the MonetDB/X100 model: every operator (FilterTransform, ExpressionTransform, AggregatingTransform, MergeSortingTransform …) consumes and produces a chunk — a set of columns holding up to max_block_size rows (default 65,409). A filter evaluates region = 'eu-west-1' over 65 K values in a tight loop on a contiguous array, producing a UInt8 mask; the mask is then applied to the other columns with a single pass. This is the CPU-side half of why ClickHouse is so fast.
Three CPU-level effects follow. Instruction cache: the loop body is a few hundred bytes executed tens of thousands of times, so branch prediction and the µop cache work as designed. SIMD: hot kernels (arithmetic, comparisons, hashing, memcpy-like moves, aggregate state updates) are compiled in multiple variants and selected at runtime by CPU dispatch — SSE 4.2, AVX2, AVX-512 — without a separate build. Virtual-call amortisation: the interpreter overhead of choosing which typed implementation to run is paid once per chunk, not once per row.
Since 22.x ClickHouse can additionally JIT-compile chains of scalar expressions and aggregate-state updates with LLVM (compile_expressions, compile_aggregate_expressions, on by default after a query shape has been seen min_count_to_compile_expression times), fusing adjacent operators into one native loop. Its benefit is real on expression-heavy queries and negligible on I/O-bound ones — measure with ProfileEvents['CompileFunction'] before crediting it.
See the processors: EXPLAIN PIPELINE
EXPLAIN PIPELINE graph = 0
SELECT customer_id, sum(amount)
FROM events
WHERE event_date BETWEEN '2026-08-01' AND '2026-08-07'
GROUP BY customer_id
SETTINGS max_threads = 8;Read it bottom-up: MergeTreeSelect × 8 (one stream per thread over disjoint mark ranges) → ExpressionTransform × 8 → AggregatingTransform × 8 (each thread builds its own hash table) → Resize → a merging step that combines the eight partial states. The × N multipliers are the intra-node parallelism of choice 6 made visible. Our step-by-step guide to reading EXPLAIN PIPELINE covers what each processor means.
Parallelism at three levels: cores, shards, and parallel replicas
Why ClickHouse is so fast · reason 6 of 7
Most warehouses parallelise across nodes. ClickHouse is so fast under load because it parallelises at three multiplicative levels, and — critically — the unit of work at every level is the same object: a range of marks in a part. On clusters, this is why ClickHouse is so fast at scale rather than only on a single large node.
Level 1 — threads within a node. A single SELECT spreads its mark ranges over max_threads (defaults to the physical core count). Each thread runs its own copy of the pipeline in Figure 4 and builds a private aggregation hash table; states are merged at the end. Because the partial states live in a shared arena, ClickHouse can also convert to a two-level hash table mid-query when cardinality grows, so merging is bucket-parallel rather than serial.
Level 2 — shards. A Distributed table fans the query out to every shard, which executes it locally on its own data and returns partial aggregate states (not rows) to the initiator. With distributed_aggregation_memory_efficient the merge is streamed and bounded in memory.
Level 3 — parallel replicas. Introduced in 23.x, hardened through 24.x–25.x and the default machinery in ClickHouse Cloud: replicas of the same shard can cooperate on one query: the initiator hands out mark ranges dynamically and each replica reads a disjoint slice. This is what turns “a replica is a warm standby” into “a replica is compute”. Enable it with enable_parallel_replicas = 1, cluster_for_parallel_replicas, max_parallel_replicas; it requires the new analyzer (enable_analyzer = 1, the default since 24.3).
max_threads or enabling parallel replicas increases per-query memory (one hash table per thread) and can starve concurrent queries. Set max_memory_usage and max_concurrent_queries in the same change, stage it on one replica, and confirm with system.query_log.memory_usage at p99 before rolling out. Sharding, distributed tables and parallel replicas covers the topology decisions in depth.Merge-time precomputation: materialized views, projections and caches
Why ClickHouse is so fast · reason 7 of 7
The fastest query is one whose answer already exists — ClickHouse is so fast on dashboards largely because it precomputes. ClickHouse’s LSM-style storage — immutable parts merged in the background — gives it a place to do work between ingest and query that row stores do not have. Three mechanisms use it, and together they complete the picture of why ClickHouse is so fast for repeated dashboard and reporting queries.
Incremental materialized views
An insert trigger: every block inserted into the source table is run through the view’s SELECT and the result written to a target table, typically SummingMergeTree or AggregatingMergeTree. The target’s merges then fold partial aggregate states together, so a dashboard query reads hundreds of pre-aggregated rows instead of billions of raw ones. Since 24.x refreshable materialized views add scheduled full recomputation for cases where incremental semantics do not fit.
Projections
A hidden copy of the table (or a pre-aggregation of it) stored inside each part with a different ORDER BY. The optimiser picks the projection automatically when it would read fewer marks. Maintained by the same insert/merge path, so it is transparent to writers — at the cost of write amplification and storage. The right answer to “my second-most-common filter column is not in the sort key”.
Query cache & page caches
use_query_cache = 1 (since 23.x) stores whole result sets keyed by the normalised AST, with TTL and per-user isolation — safe for repeated dashboard queries, not for anything with now() (see how the query cache is implemented). Beneath it, the OS page cache, the mark cache, the uncompressed cache and the object-storage filesystem cache (S3/Azure/GCS tiers) each remove a layer of I/O for hot data.
Aggregating materialized view pattern
CREATE TABLE events_daily_by_customer
(
event_date Date,
customer_id UInt64,
total_amount AggregateFunction(sum, Decimal(18, 4)),
p95_latency AggregateFunction(quantile(0.95), UInt32),
event_count AggregateFunction(count)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(event_date)
ORDER BY (event_date, customer_id);
CREATE MATERIALIZED VIEW mv_events_daily_by_customer
TO events_daily_by_customer
AS
SELECT
event_date,
customer_id,
sumState(amount) AS total_amount,
quantileState(0.95)(latency_ms) AS p95_latency,
countState() AS event_count
FROM events
GROUP BY event_date, customer_id;
-- Query with -Merge combinators; GROUP BY must match target ORDER BY.
SELECT
customer_id,
sumMerge(total_amount) AS total_amount,
quantileMerge(0.95)(p95_latency) AS p95_latency
FROM events_daily_by_customer
WHERE event_date BETWEEN '2026-08-01' AND '2026-08-07'
GROUP BY customer_id
ORDER BY total_amount DESC
LIMIT 100;Projection for a non-key filter column
ALTER TABLE events
ADD PROJECTION prj_events_by_customer
(
SELECT *
ORDER BY (customer_id, event_date)
);
-- Builds the projection for existing parts (a mutation; stage it).
ALTER TABLE events MATERIALIZE PROJECTION prj_events_by_customer;
-- Confirm the optimiser chose it: look for the projection
-- name in the ReadFromMergeTree description.
EXPLAIN indexes = 1
SELECT count() FROM events WHERE customer_id = 8812734;
-- Or after the fact, from the query log:
SELECT query_id, projections, read_rows, read_bytes
FROM system.query_log
WHERE type = 'QueryFinish'
AND has(tables, concat(currentDatabase(), '.events'))
ORDER BY event_time DESC
LIMIT 20;Projections are incompatible with lightweight deletes/updates and cannot carry a WHERE or JOIN in their definition (26.3). Where those constraints bite, a materialized view into a second table is the fallback. Our projections guide and chained materialized views post go further; the official reference is the incremental materialized view and projections documentation.
Why ClickHouse is so fast at the micro level: the engineering that compounds the design
The seven choices above are the architecture — the structural answer to why ClickHouse is so fast. What separates ClickHouse from other engines with the same architecture on paper is the volume of specialised implementations behind each operator, all selected at runtime. The VLDB 2024 paper documents this in detail; the items below are the ones that show up most often in profiles.
- 30+ hash-table variants for
GROUP BY. Key type (fixed-width integers, one string, several strings, nullable, low-cardinality) and cardinality estimate select among specialised tables — including a two-level table that is converted to on the fly when a threshold is crossed so that the final merge across threads is parallel per bucket. - Adaptive JOIN algorithms.
hash,parallel_hash,grace_hash,partial_merge,full_sorting_mergeanddirect(dictionary/KV lookups) are all available;join_algorithm = 'auto'starts with hash and spills to a merge join when the right-hand side exceeds memory limits. Recent analyzer releases also reorder joins and push predicates through them. - Multiple sort implementations. Partial sorting with
LIMITpushdown, radix sort for numeric keys, andoptimize_read_in_orderwhich streams already-sorted parts throughORDER BYwithout a sort step when the sort matches the table key. - Table-engine specialisation.
ReplacingMergeTree,SummingMergeTree,AggregatingMergeTree,CollapsingMergeTreeandVersionedCollapsingMergeTreemove deduplication and roll-up logic into merges. For ingest-heavy key-value workloads ChistaDATA runsEmbeddedRocksDBalongside MergeTree, using RocksDB’s LSM as the write-optimised path. - Concurrency without shared locks. Reads see a consistent snapshot of the active part set via multi-versioning; inserts create new parts; merges replace parts atomically. There is no buffer-pool latch contention of the kind that limits OLTP engines under mixed load, which is why a single node comfortably serves hundreds of concurrent analytical sessions given adequate memory.
Related settings — current defaults on 26.3, per query or profile
| Setting | Default | Effect on speed |
|---|---|---|
max_threads | physical cores | Level-1 parallelism; memory scales with it |
max_block_size | 65 409 | Rows per chunk in the pipeline |
compile_expressions | 1 | LLVM JIT for expression chains |
join_algorithm | direct,parallel_hash,hash | Join strategy selection order |
optimize_read_in_order | 1 | Skip sort when ORDER BY matches key |
use_query_cache | 0 | Result-set caching (opt-in) |
enable_parallel_replicas | 0 | Level-3 parallelism (opt-in in OSS) |
enable_analyzer | 1 | New query analyzer/planner |
Verify defaults for your build with SELECT name, value, changed FROM system.settings WHERE name IN (…). Session-level settings apply on the next query; server-level settings in config.xml (e.g. cache sizes) require a restart.
Where ClickHouse is not fast — and what to do about it
Every design choice above is a trade. Recognising the workloads on the wrong side of those trades is what keeps a ClickHouse deployment fast in year three, not just in the proof of concept. Understanding why ClickHouse is so fast also tells you exactly where it is not.
Point lookups & row-level updates
A primary key that addresses 8,192 rows at a time is the wrong tool for “fetch one row by ID”. Lightweight UPDATE/DELETE (25.x+) are usable but are still mutations under the hood. Keep OLTP in PostgreSQL/MySQL and stream changes in via CDC (Debezium, Kafka engine, ClickPipes-style connectors).
Large JOINs on the right-hand side
Hash joins build the right table in memory per query. A billion-row fact-to-fact join without denormalisation, dictionaries or grace_hash will exhaust max_memory_usage. Model for star schemas with dictionaries or pre-joined materialized views.
Many tiny inserts
Each INSERT creates a part; thousands per second overwhelm merges and trip parts_to_throw_insert. Batch at the client, or enable async_insert = 1 so the server batches for you. This is an ingestion-design issue, not a read-path one.
Vendor-neutral note: if your workload is primarily the first column, ClickHouse is the wrong engine and we will say so — MinervaDB’s PostgreSQL and MySQL practices exist for exactly that split. For a structured comparison, see the CTO’s guide to columnstores vs row stores.
How to measure why ClickHouse is so fast on your own cluster
Every claim on this page about why ClickHouse is so fast maps to a column in system.query_log. The query below is the one we run first in every ChistaDATA performance engagement: it shows, per query, how many bytes Phase 1 let through and how much CPU Phase 2 spent on them.
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_selected,
avg(ProfileEvents['SelectedParts']) AS avg_parts_selected,
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;How to read it
| Signal | Points at | Fix belongs to |
|---|---|---|
avg_marks_selected ≈ total marks in table | Primary index not used | Choice 2 — reorder ORDER BY |
High read_bytes, few read_rows | Wide columns or poor codec | Choices 1 & 4 |
High avg_cpu_ms ≫ p95_ms | Good parallelism, CPU-bound | Choice 7 — precompute |
p95_ms ≫ avg_cpu_ms | I/O or lock/merge wait | Storage tier, system.merges |
max_memory near max_memory_usage | Hash table / join spill risk | Choice 6 settings, join algorithm |
Complement it with system.parts (part count, granule count, compression), system.merges (background pressure), system.trace_log with query_profiler_cpu_time_period_ns for flame graphs, and EXPLAIN PIPELINE for processor-level shape. That is the entire toolkit; there is nothing hidden.
Why ClickHouse is so fast — common questions
Why ClickHouse is so fast — is it because it is in-memory?
No. ClickHouse is a disk-based (increasingly object-storage-based) columnar database. ClickHouse is so fast because it reads a small fraction of the stored bytes (columnar layout, sparse primary index, skip indexes, compression) and processes them with vectorized, multi-threaded operators. Memory is used for caches and aggregation state, not as the primary store.
Why ClickHouse is so fast compared with PostgreSQL or MySQL for analytics?
Row stores keep all of a row’s columns together and index individual rows, which is ideal for OLTP and wrong for wide-table aggregation. ClickHouse is so fast by comparison because it reads only the referenced columns, addresses data in 8,192-row granules, and executes chunk-at-a-time. On the same hardware the byte volume read per analytical query is typically one to two orders of magnitude lower. For transactional workloads the comparison reverses.
How does ClickHouse compare to Snowflake, BigQuery or Redshift on speed?
All four are columnar and parallel, so the reasons ClickHouse is so fast against them are narrower than against row stores. ClickHouse’s distinctive advantages are the explicit sparse primary index and skip indexes (deterministic, DDL-controlled pruning), merge-time precomputation (materialized views and projections inside the storage engine), and a runtime with many specialised operator implementations. Whether that translates into a lower latency for your workload is a measurement question — ClickBench publishes the methodology; run it against your own queries.
Does ClickHouse Cloud share the same reasons why ClickHouse is so fast?
Yes for the read path. ClickHouse Cloud’s SharedMergeTree separates compute from object storage and relies heavily on parallel replicas and the filesystem cache, and defaults to ZSTD compression. The primary-index, skip-index, vectorization and materialized-view mechanics are identical to open-source ClickHouse, which is what ChistaDATA operates for customers on their own infrastructure.
What is the single most important design decision for ClickHouse performance?
The ORDER BY key of each MergeTree table — most of what makes ClickHouse so fast flows from it. It determines what the primary index can prune, how well columns compress, whether optimize_read_in_order applies, and which projections you will later need. Get it right from query patterns before loading data; changing it afterwards means rebuilding the table.
Which ClickHouse version does this explanation of why ClickHouse is so fast describe?
Behaviour and defaults were verified against ClickHouse 26.3 LTS (the current long-term-support line, alongside 25.8 LTS) with 26.7 the latest stable release at the time of writing (August 2026). Version-sensitive features are pinned inline.
Knowing why ClickHouse is so fast is the start. Keeping it fast in production is engineering.
ChistaDATA provides 24×7×365 consultative support, performance engineering, platform engineering and managed services for 100% open-source ClickHouse — with SLA-backed response times (S1 within 15 minutes) and zero vendor lock-in. If ClickHouse is so fast by design but your p95 is not where this page says it should be, we will find out why, with the same system tables shown above.
Using indexes in ClickHousePractical guide to primary and skip indexes
Vectorized query processingHow chunk-at-a-time execution works
Finding bottlenecks with EXPLAIN PIPELINEDecode processor graphs
Materialized views in ClickHouseIncremental aggregation patterns
ChistaDATA UniversityClickHouse training for engineering teams