ClickHouse, explained with animations.

Visual guides to how ClickHouse stores, skips, merges and serves data—so you can use it efficiently from day one.

Why is ClickHouse fast? It reads less. #

One query, three cuts. Partitions, indexes and columns take about 8 TiB of raw data—about 1.7 TiB compressed on disk—down to about 0.9 MiB read.

Large table, small read
1Query1SELECT device_type, sum(revenue)FROM eventsWHERE event_type = 'purchase'  AND timestamp >= now() - INTERVAL 7 DAY  AND revenue > 500GROUP BY device_typeLeft to readCompressed table size1.7 TiB≈ 156 GiB≈ 182 MiB≈ 0.9 MiB1.7 TiBon disk0.0001 %0.01 %1 %100 %8.89 %partitions×0.114 %granules×0.48 %column bytes≈ 0.000049 %log scale · columns cut bytes, not rows2Partition pruningskips whole partitions23Primary indexindexes narrow the granules34Column readscolumnar storage · separate streams45Resultmerge partials582 skippedkept: the 8 days the 7-day window touchesPARTITION BY toDate(timestamp)8 / 90partitions keptIn RAM≈ 38 MiB primary index, if fully loadedPrimary index · 1 mark per granule = its first rowSkip index · revenue min/max per granule, for revenue > 500On disk · one day1 slice ≈ 768 granulesGranule = 8,192 rows, a logical group inside a data file≈ 24 / 18,432per full day · ≈ 168 across the rolling 7-day windowRead orderOn diskevent_typetimestamprevenuedevice_typetenant_idpayloadevent_id4 of 7columns read · ≈ 0.5 % of the bytessum(revenue) per device_typedevice_typesum(revenue)desktop18,240.00mobile9,730.50tablet3,120.00smart_tv1,540.00console620.00≈ 0.9 MiBcompressed data read≈ 8 TiBraw size of whole table
1Query1SELECT device_type, sum(revenue)FROM eventsWHERE event_type = 'purchase'  AND timestamp >= now() - INTERVAL 7 DAY  AND revenue > 500GROUP BY device_typeLeft to readCompressed table size1.7 TiB≈ 156 GiB≈ 182 MiB≈ 0.9 MiB1.7 TiBon disk0.0001 %0.01 %1 %100 %8.89 %partitions×0.114 %granules×0.48 %column bytes≈ 0.000049 %of the tablelog scale · columns cut bytes, not rows2Partition pruning2PARTITION BY toDate(timestamp)8 / 9082 skippedkept: the 8 days the 7-day window touches3Primary index3In RAM≈ 38 MiB if fully loadedPrimary index · 1 mark per granuleSkip index · revenue min/maxOn disk · one dayGranule = 8,192 rows (default),a logical group inside a data file≈ 24 / 18,432per full day · ≈ 168 in the rolling 7-day window4Column reads4columnar storage · separate streamsRead orderOn diskevent_typetimestamprevenuedevice_typetenant_idpayloadevent_id4 of 75Result5sum(revenue) per device_typedevice_typesum(revenue)desktop18,240.00mobile9,730.50tablet3,120.00smart_tv1,540.00console620.00≈ 0.9 MiBcompressed data read≈ 8 TiBraw size of whole table
Illustrative exampleSizes and ratios are typical, not measured; the picture is not to scale.

Check it on your own table. Put EXPLAIN indexes = 1 in front of a query: ClickHouse prints how many parts and granules each index keeps, without running the query.

EXPLAIN indexes = 1SELECT device_type, sum(revenue)FROM eventsWHERE event_type = 'purchase'  AND timestamp >= now() - INTERVAL 7 DAY  AND revenue > 500GROUP BY device_type
What it prints 23 lines, shortened
ReadFromMergeTree (default.events)Indexes:  Min-Max    Keys:      timestamp    Parts: 8/90    Granules: 147456/1658880  Partition    Keys:      toDate(timestamp)    Parts: 8/8    Granules: 147456/147456  PrimaryKey    Keys:      event_type      timestamp    Parts: 8/8    Granules: 252/147456  Skip    Name: idx_rev    Description: minmax GRANULARITY 1    Parts: 8/8    Granules: 168/252

These are the example's counts. Each reads kept/before, so 8/90 is 8 of 90 parts, one per day here, and each step starts from what the step above kept.

Min-Max and Partition are step 2 of the figure, partition pruning: the first checks each part's smallest and largest timestamp, the second its partition value.

PrimaryKey and Skip are step 3: the primary index keeps 252 granules and the skip index leaves 168 to read. The column cut, step 4, is not an index and is not listed.

Releases before 26.6 print MinMax instead of Min-Max. See the reference, EXPLAIN .

Columnar storage

Read only the columns you need. #

Columns are stored and compressed separately. The same query reads 4 of 7 columns and skips tenant_id, event_id and the wide payload: about 8.5 GiB instead of 4.1 TiB, before any rows are skipped.

Row layout vs column layout
1Query1SELECT device_type, sum(revenue)FROM eventsWHERE event_type = 'purchase'  AND timestamp >= now() - INTERVAL 7 DAY  AND revenue > 500GROUP BY device_typeColumns neededevent_typetimestamprevenuedevice_type4 of 7 columns · event_id, tenant_id, payload skippedall 4 rows are read, then filtered: r1 and r3 match2Row storefields of one row stored together, row after row23Column storevalues of one column stored together, column after column3e.g.PostgreSQLPostgreSQLMySQLMySQLMariaDBMariaDBSQLiteSQLiteevent_idtenant_idevent_typetimestampdevice_typerevenuepayloadr19f3c…4821purchase·desktop842.00{…}r20b7e…4821view·mobile0.00{…}r3c41a…1057purchase·tablet980.00{…}r45d92…4821purchase·mobile120.00{…}wastedwastedreadreadreadreadwastedOn disk · compressedmixed types side by side203 GiB1×26 GiB2×50 GiB2×25 GiB2×45 GiB2×51 GiB2×3.8 TiB2×whole table: ≈ 8 TiB raw → ≈ 4.1 TiB on disk (≈ 2×)mixed types per page: ≈ 2× · outline = raw size≈ 4.1 TiBread before any pruning · all 7 columnse.g.ClickHouse9f3c…0b7e…c41a…5d92…event_id4821482110574821tenant_idpurchaseviewpurchasepurchaseevent_type····timestampdesktopmobiletabletmobiledevice_type842.000.00980.00120.00revenue{…}{…}{…}{…}payloadskippedskippedreadreadreadreadskippedOn disk · compressedsimilar values side by side203 GiB1×5.1 GiB10×100 MiB1000×250 MiB200×3 GiB30×5.1 GiB20×1.5 TiB5×whole table: ≈ 8 TiB raw → ≈ 1.7 TiB on disk (≈ 5×)same-type values: up to 1000× · outline = raw size≈ 8.5 GiBread before any pruning · 4 of 7 columns
1Query1SELECT device_type, sum(revenue)FROM eventsWHERE event_type = 'purchase'  AND timestamp >= now() - INTERVAL 7 DAY  AND revenue > 500GROUP BY device_typeneeds 4 of 7 columns · skips the other 3all 4 rows read, then filtered: r1 and r3 match2Row storeone row's fields together2≈ 4.1 TiBread, whole tablee.g.PostgreSQLPostgreSQLMySQLMySQLMariaDBMariaDBSQLiteSQLiteevent_idtenant_idevent_typetimestampdevice_typerevenuepayloadr19f3c…4821purchase·desktop842.00{…}r20b7e…4821view·mobile0.00{…}r3c41a…1057purchase·tablet980.00{…}r45d92…4821purchase·mobile120.00{…}wastedwastedreadreadreadreadwastedOn disk · compressed203 GiB1×26 GiB2×50 GiB2×25 GiB2×45 GiB2×51 GiB2×3.8 TiB2×whole table: ≈ 8 TiB → ≈ 4.1 TiB (≈ 2×)mixed types per page: ≈ 2× · outline = raw size3Column storeone column's values together3≈ 8.5 GiBread, whole tablee.g.ClickHouse9f3c…0b7e…c41a…5d92…event_id4821482110574821tenant_idpurchaseviewpurchasepurchaseevent_type····timestampdesktopmobiletabletmobiledevice_type842.000.00980.00120.00revenue{…}{…}{…}{…}payloadskippedskippedreadreadreadreadskippedOn disk · compressed203 GiB1×5.1 GiB10×100 MiB1000×250 MiB200×3 GiB30×5.1 GiB20×1.5 TiB5×whole table: ≈ 8 TiB → ≈ 1.7 TiB (≈ 5×)same-type values: up to 1000× · outline = raw size
Illustrative example

payload, a JSON string, is 1.5 of the 1.7 TiB on disk.

When queries need fields from inside it, the JSON type lets them read single paths instead of the whole string. See the official guide, Use JSON where appropriate .

Primary index · skip index

Sort once. Skip granules before reading. #

Two small indexes decide what to read. The same query: the primary index finds the key range, a skip index drops granules whose min/max cannot match. Across the rolling 7‑day window that leaves about 168 of ≈ 1.66 M granules; the 8 below show how.

Sorted data, sparse primary index, skip index
1Query1SELECT device_type, sum(revenue)FROM eventsWHERE event_type = 'purchase'  AND timestamp >= now() - INTERVAL 7 DAY  AND revenue > 500GROUP BY device_type← primary key← skip indexexample: 2025-06-15 10:002Table definition2INDEX idx_rev revenue TYPE minmax GRANULARITY 1PARTITION BY toDate(timestamp)ORDER BY (event_type, timestamp)rows are stored sorted by this key; it becomes the primary indexSETTINGS index_granularity = 81923One part of 2025-06-08, two small indexesthe query wants purchases over 500 since 2025-06-08 10:00, 7 days ago; the indexes rule out the rest firstexample: 2025-06-15 10:003On disk · 2025-06-08granuleone part (inserts 08:00–12:00) · 8 of its granulesevent_typetimestamptenant_idrevenue min–maxg0✗ primaryclick2025-06-08 11:59:5110570 – 0+ 8,191 more rows · ≈ 5.3 KiB in the 4 query columnsg1✗ primarypurchase2025-06-08 08:00:0132100.50 – 1,870+ 8,191 more rows · ≈ 5.3 KiB in the 4 query columnsg2✓ readpurchase2025-06-08 09:12:1248214.90 – 2,310+ 8,191 more rows · ≈ 5.3 KiB in the 4 query columnsg3✓ readpurchase2025-06-08 10:21:0510570.99 – 1,240+ 8,191 more rows · ≈ 5.3 KiB in the 4 query columnsg4✗ skip idxpurchase2025-06-08 10:58:3155301.20 – 310+ 8,191 more rows · ≈ 5.3 KiB in the 4 query columnsg5✓ readpurchase2025-06-08 11:26:5848212.10 – 980+ 8,191 more rows · ≈ 5.3 KiB in the 4 query columnsg6✗ primaryview2025-06-08 08:00:0032100 – 0+ 8,191 more rows · ≈ 5.3 KiB in the 4 query columnsg7✗ primaryview2025-06-08 08:00:0910570 – 0+ 8,191 more rows · ≈ 5.3 KiB in the 4 query columnsblue = first row · granule = 8,192 rows, a logical group inside a data fileIn RAMloaded or cached as neededprimary.idx · ≈ 38 MiBskip · ≈ 25 MiBmark 0click2025-06-08 11:59:51mark 1purchase2025-06-08 08:00:01mark 2purchase2025-06-08 09:12:12mark 3purchase2025-06-08 10:21:05mark 4purchase2025-06-08 10:58:31mark 5purchase2025-06-08 11:26:58mark 6view2025-06-08 08:00:00mark 7view2025-06-08 08:00:090 – 0not checked0.50 – 1,870not checked4.90 – 2,310✓ may match0.99 – 1,240✓ may match1.20 – 310✗ max ≤ 5002.10 – 980✓ may match0 – 0not checked0 – 0not checkedPrimary index → keeps g2–g5Skip index → drops g4 (max 310)Read → g2, g3, g53 / 8of these granules read · 24,576 rows, ≈ 16 KiB · g2 is a boundary: its last rows may be after 2025-06-08 10:00
1Query1SELECT device_type, sum(revenue)FROM eventsWHERE event_type = 'purchase'  AND timestamp >= now() - INTERVAL 7 DAY  AND revenue > 500GROUP BY device_typeexample: 2025-06-15 10:002Table definition2INDEX idx_rev revenue TYPE minmax  GRANULARITY 1PARTITION BY toDate(timestamp)ORDER BY (event_type, timestamp)SETTINGS index_granularity = 81923Indexesexample: 2025-06-15 10:0033 / 8In RAMloaded or cachedprimary.idx · ≈ 38 MiBskip · ≈ 25 MiBm0click2025-06-08 11:59:51m1purchase2025-06-08 08:00:01m2purchase2025-06-08 09:12:12m3purchase2025-06-08 10:21:05m4purchase2025-06-08 10:58:31m5purchase2025-06-08 11:26:58m6view2025-06-08 08:00:00m7view2025-06-08 08:00:090 – 0·0.50 – 1,870·4.90 – 2,310✓0.99 – 1,240✓1.20 – 310✗2.10 – 980✓0 – 0·0 – 0·mark k ↔ granule k · index data is in RAM while analyzedOn disk · 2025-06-08one part · 8 of its granulesevent_typetimestamprevenuegranuleg0click2025-06-08 11:59:510 – 0+ 8,191 more rows · ≈ 5.3 KiB✗ primaryg1purchase2025-06-08 08:00:010.50 – 1,870+ 8,191 more rows · ≈ 5.3 KiB✗ primaryg2purchase2025-06-08 09:12:124.90 – 2,310+ 8,191 more rows · ≈ 5.3 KiB✓ readg3purchase2025-06-08 10:21:050.99 – 1,240+ 8,191 more rows · ≈ 5.3 KiB✓ readg4purchase2025-06-08 10:58:311.20 – 310+ 8,191 more rows · ≈ 5.3 KiB✗ skip idxg5purchase2025-06-08 11:26:582.10 – 980+ 8,191 more rows · ≈ 5.3 KiB✓ readg6view2025-06-08 08:00:000 – 0+ 8,191 more rows · ≈ 5.3 KiB✗ primaryg7view2025-06-08 08:00:090 – 0+ 8,191 more rows · ≈ 5.3 KiB✗ primaryblue = first row · granule = 8,192 rows,a logical group inside a data filePrimary → g2–g5: purchases from 2025-06-08 10:00Skip index → g4 max 310 ≤ 500, droppedRead → g2, g3, g5 · 24,576 rows, ≈ 16 KiB
Illustrative example

The 8 granules are neighbours in one part of partition 2025-06-08, the day the 7‑day window starts.

That part holds the rows inserted 08:00–12:00, sorted by event_type and timestamp.

Across the whole 7‑day window, the primary index keeps about 252 granules and the skip index leaves about 168 of them: ≈ 0.9 MiB to read, the total in the first figure.

Materialized views · table engines

Prepare results at insert time. #

Views feed small, pre-aggregated tables. Each insert is summarized once, as it arrives, and the table engine merges the summaries in the background. Dashboards read a few hundred rows instead of billions.

Materialized views and table engines
1Insert time · one write feeds every vieweach view sees only the new block and writes a few summary rows to its table1Insertone blockeventsMergeTree · raw · 1.7 TiBmv · sum per dayrevenue_dailySummingMergeTree(day, device) → sum(revenue)mv · active tenantstenants_dailyAggregatingMergeTreeday → uniqState(tenant_id)mv · latest eventtenants_latestReplacingMergeTreetenant_id → newest event2SummingMergeTreemerge: add the values2already stored2025-06-15desktop1,240.00from this insert2025-06-15desktop610.00background merge2025-06-15desktop1,850.003AggregatingMergeTreemerge: combine partial states3already stored2025-06-15uniq {t1, t2}from this insert2025-06-15uniq {t2, t3}background merge2025-06-15{t1, t2, t3} → 34ReplacingMergeTreemerge: keep the newest row4already storedtenant 42view · 09:12from this inserttenant 42click · 10:05background mergetenant 42click · 10:055Query time · read the prepared tables5SELECT day, sum(revenue)FROM revenue_daily GROUP BY daySELECT day, uniqMerge(tenants)FROM tenants_daily GROUP BY daySELECT event_type FROM tenants_latestFINAL WHERE tenant_id = 42merges run in the background, so queries stillaggregate (sum, uniqMerge, FINAL), over a few rowsRows read per dashboard querylog scaleevents, raw13.6 B rowsrevenue_daily≈ 450 rowstenants_daily≈ 90 rowscost: every insert also writes every view,and a view speeds up only the queries it was built for
1Insert timeviews see only the new block1Insertone blockeventsMergeTree · raw · 1.7 TiBfires three viewsmv · sum per day →revenue_dailySummingMergeTree(day, device) → sum(revenue)mv · active tenants →tenants_dailyAggregatingMergeTreeday → uniqState(tenant_id)mv · latest event →tenants_latestReplacingMergeTreetenant_id → newest event2SummingMergeTreemerge: add the values22025-06-15desktop1,240.002025-06-15desktop610.002025-06-15desktop1,850.00after merge3AggregatingMergeTreemerge: combine partial states32025-06-15uniq {t1, t2}2025-06-15uniq {t2, t3}2025-06-15{t1, t2, t3} → 3after merge4ReplacingMergeTreemerge: keep the newest row4tenant 42view · 09:12tenant 42click · 10:05tenant 42click · 10:05after merge5Query time5SELECT day, sum(revenue)FROM revenue_daily GROUP BY daySELECT day, uniqMerge(tenants)FROM tenants_daily GROUP BY daySELECT event_type FROM tenants_latestFINAL WHERE tenant_id = 42merges run in the background, so queriesstill aggregate, over a few rowsRows read per querylog scaleevents, raw13.6 B rowsrevenue_daily≈ 450 rowstenants_daily≈ 90 rowscost: every insert also writes every view
Illustrative example