ClickHouse, explained in pictures.

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

The short answer: it reads less.

One query, three cuts. Partitions, indexes and columns take a 2 TiB table down to about 4.5 MiB.

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 readTotal table size2 TiB≈ 182 GiB≈ 243 MiB≈ 4.5 MiB2 TiBstored0.0001 %0.01 %1 %100 %8.89 %partitions×0.130 %granules×1.86 %column bytes≈ 0.00022 %log scale · columns cut bytes, not rows2Partition pruningskips whole partitions23Primary indexRAM index locates granules34Column readscolumnar storage · a file per column45Resultmerge partials582 skippedkept: last 8 daysPARTITION BY toDate(timestamp)8 / 90partitions keptKept in RAM≈ 38 MiB of RAM · whole tablePrimary 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 file24 / 18,432granules read per day · primary index 36 → skip index 24Read orderOn diskevent_typetimestamprevenuedevice_typetenant_idpayloaduser_idsession_idbrowsercountryreferrer_urlcurrency4 of 12columns read · ≈ 1.9 % of the bytessum(revenue) per device_typedevice_typesum(revenue)desktop18,240.00mobile9,730.50tablet3,120.00smart_tv1,540.00console620.005 rowsreturned · one per device_type
1Query1SELECT device_type, sum(revenue)FROM eventsWHERE event_type = 'purchase'  AND timestamp >= now() - INTERVAL 7 DAY  AND revenue > 500GROUP BY device_typeLeft to readTotal table size2 TiB≈ 182 GiB≈ 243 MiB≈ 4.5 MiB2 TiBstored0.0001 %0.01 %1 %100 %8.89 %partitions×0.130 %granules×1.86 %column bytes≈ 0.00022 %of the tablelog scale · columns cut bytes, not rows2Partition pruning2PARTITION BY toDate(timestamp)8 / 9082 skippedkept: last 8 days3Primary index3Kept in RAM≈ 38 MiB RAM totalPrimary index · 1 mark per granuleSkip index · revenue min/maxOn disk · one dayGranule = 8,192 rows (default),a logical group inside a data file24 / 18,4321 slice ≈ 768 granules · 99.87 % skipped4Column reads4columnar storage · a file per columnRead orderOn diskevent_typetimestamprevenuedevice_typetenant_idpayloaduser_idsession_idbrowsercountryreferrer_urlcurrency4 of 125Result5sum(revenue) per device_typedevice_typesum(revenue)desktop18,240.00mobile9,730.50tablet3,120.00smart_tv1,540.00console620.005 rowsreturned
Illustrative exampleSizes and ratios are typical, not measured; the picture is not to scale.

Columnar storage

Read only the columns you need.

Each column is its own compressed file. The same query opens 4 of them and skips tenant_id and the wide payload: about 38 GiB instead of 5 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 the 6 columns shown · tenant_id and 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, one file each3e.g.PostgreSQLPostgreSQLMySQLMySQLMariaDBMariaDBSQLiteSQLitetenant_idevent_typetimestampdevice_typerevenuepayloadr14821purchase·desktop842.00{…}r24821view·mobile0.00{…}r31057purchase·tablet980.00{…}r44821purchase·mobile120.00{…}wastedreadreadreadreadwastedOn disk · compressedmixed types side by siderow 1row 2row 3row 4whole table: ≈ 10 TiB raw → ≈ 5 TiB on disk (≈ 2×)mixed types per page: compresses poorly≈ 5 TiBread before any pruning · all 6 columns, 24 of 24 valuese.g.ClickHouse4821482110574821tenant_idpurchaseviewpurchasepurchaseevent_type····timestampdesktopmobiletabletmobiledevice_type842.000.00980.00120.00revenue{…}{…}{…}{…}payloadskippedreadreadreadreadskippedOn disk · compressedsimilar values side by side5.1 GiB10×1 GiB100×2.5 GiB20×0.9 GiB100×34 GiB3×1.5 TiB5×whole table: ≈ 10 TiB raw → ≈ 2 TiB on disk (≈ 5×)one type per file: 3× to 100× · outline = raw size≈ 38 GiBread before any pruning · 4 of 6 columns, 16 of 24 values
1Query1SELECT device_type, sum(revenue)FROM eventsWHERE event_type = 'purchase'  AND timestamp >= now() - INTERVAL 7 DAY  AND revenue > 500GROUP BY device_typeneeds 4 of 6 columns · skips tenant_id, payloadall 4 rows read, then filtered: r1 and r3 match2Row storeone row's fields together2≈ 5 TiBread, whole tablee.g.PostgreSQLPostgreSQLMySQLMySQLMariaDBMariaDBSQLiteSQLitetenant_idevent_typetimestampdevice_typerevenuepayloadr14821purchase·desktop842.00{…}r24821view·mobile0.00{…}r31057purchase·tablet980.00{…}r44821purchase·mobile120.00{…}wastedreadreadreadreadwastedOn disk · compressedrow 1row 2row 3row 4whole table: ≈ 10 TiB → ≈ 5 TiB (≈ 2×)mixed types per page: compresses poorly3Column storeone column's values together3≈ 38 GiBread, whole tablee.g.ClickHouse4821482110574821tenant_idpurchaseviewpurchasepurchaseevent_type····timestampdesktopmobiletabletmobiledevice_type842.000.00980.00120.00revenue{…}{…}{…}{…}payloadskippedreadreadreadreadskippedOn disk · compressed5.1 GiB10×1 GiB100×2.5 GiB20×0.9 GiB100×34 GiB3×1.5 TiB5×whole table: ≈ 10 TiB → ≈ 2 TiB (≈ 5×)one type per file: 3× to 100× · outline = raw size
Illustrative exampleColumns only: sizes are for the whole table before any pruning, so every row of the 4 columns is read and then filtered. The next section shows how the indexes skip most of these rows. Sizes and ratios are typical, not measured.

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 table that leaves 192 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 indexnow() = 2025-06-15 10:00:00 UTC2Table 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 from 2025-06-08 10:00:00 UTC on (now() minus 7 days); the indexes rule out the rest firstnow() · 2025-06-15 10:00:00 UTC3On 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 · ≈ 24 KiB in the 4 query columnsg1✗ primarypurchase2025-06-08 08:00:0132100.50 – 1,870+ 8,191 more rows · ≈ 24 KiB in the 4 query columnsg2✓ readpurchase2025-06-08 09:12:1248214.90 – 2,310+ 8,191 more rows · ≈ 24 KiB in the 4 query columnsg3✓ readpurchase2025-06-08 10:21:0510570.99 – 1,240+ 8,191 more rows · ≈ 24 KiB in the 4 query columnsg4✗ skip idxpurchase2025-06-08 10:58:3155301.20 – 310+ 8,191 more rows · ≈ 24 KiB in the 4 query columnsg5✓ readpurchase2025-06-08 11:26:5848212.10 – 980+ 8,191 more rows · ≈ 24 KiB in the 4 query columnsg6✗ primaryview2025-06-08 08:00:0032100 – 0+ 8,191 more rows · ≈ 24 KiB in the 4 query columnsg7✗ primaryview2025-06-08 08:00:0910570 – 0+ 8,191 more rows · ≈ 24 KiB in the 4 query columnsblue = first row · granule = 8,192 rows, a logical group inside a data fileKept in RAMfor the whole tableprimary.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: always in RAM · skip: loaded on use, cachedPrimary index → keeps g2–g5Skip index → drops g4 (max 310)Read → g2, g3, g53 / 8of these granules read · 24,576 rows, ≈ 72 KiB · g2 is a boundary: its last rows may be after 2025-06-08 10:00 UTC
1Query1SELECT device_type, sum(revenue)FROM eventsWHERE event_type = 'purchase'  AND timestamp >= now() - INTERVAL 7 DAY  AND revenue > 500GROUP BY device_typenow() = 2025-06-15 10:00:00 UTC2Table definition2INDEX idx_rev revenue TYPE minmax  GRANULARITY 1PARTITION BY toDate(timestamp)ORDER BY (event_type, timestamp)SETTINGS index_granularity = 81923Indexesnow() 2025-06-15 10:00 UTC33 / 8Kept in RAMwhole tableprimary.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 · primary always in RAM, skip index cachedOn disk · 2025-06-08one part · 8 of its granulesevent_typetimestamprevenuegranuleg0click2025-06-08 11:59:510 – 0+ 8,191 more rows · ≈ 24 KiB✗ primaryg1purchase2025-06-08 08:00:010.50 – 1,870+ 8,191 more rows · ≈ 24 KiB✗ primaryg2purchase2025-06-08 09:12:124.90 – 2,310+ 8,191 more rows · ≈ 24 KiB✓ readg3purchase2025-06-08 10:21:050.99 – 1,240+ 8,191 more rows · ≈ 24 KiB✓ readg4purchase2025-06-08 10:58:311.20 – 310+ 8,191 more rows · ≈ 24 KiB✗ skip idxg5purchase2025-06-08 11:26:582.10 – 980+ 8,191 more rows · ≈ 24 KiB✓ readg6view2025-06-08 08:00:000 – 0+ 8,191 more rows · ≈ 24 KiB✗ primaryg7view2025-06-08 08:00:090 – 0+ 8,191 more rows · ≈ 24 KiB✗ primaryblue = first row · granule = 8,192 rows,a logical group inside a data filePrimary → g2–g5: purchases from 2025-06-08 10:00 UTCSkip index → g4 max 310 ≤ 500, droppedRead → g2, g3, g5 · 24,576 rows, ≈ 72 KiB
Illustrative example8 neighbouring granules of one part in partition 2025-06-08, the day the 7-day window starts: rows inserted 08:00–12:00, sorted by event_type and timestamp. Across the whole table (≈ 1.66 M granules) the primary index keeps 288 granules and the skip index 192, ≈ 4.5 MiB: 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 · 2 TiBmv · sum per dayrevenue_dailySummingMergeTree(day, device) → sum(revenue)mv · unique usersusers_dailyAggregatingMergeTreeday → uniqState(user_id)mv · latest statusorders_latestReplacingMergeTreeorder_id → newest status2SummingMergeTreemerge: add the values2already stored2025-06-15desktop1,240.00from this insert2025-06-15desktop610.00background merge2025-06-15desktop1,850.003AggregatingMergeTreemerge: combine partial states3already stored2025-06-15uniq {u1, u2}from this insert2025-06-15uniq {u2, u3}background merge2025-06-15{u1, u2, u3} → 34ReplacingMergeTreemerge: keep the newest row4already storedorder 42paid · v1from this insertorder 42shipped · v2background mergeorder 42shipped · v25Query time · read the prepared tables5SELECT day, sum(revenue)FROM revenue_daily GROUP BY daySELECT day, uniqMerge(users)FROM users_daily GROUP BY daySELECT status FROM orders_latestFINAL WHERE order_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 rowsusers_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 · 2 TiBfires three viewsmv · sum per day →revenue_dailySummingMergeTree(day, device) → sum(revenue)mv · unique users →users_dailyAggregatingMergeTreeday → uniqState(user_id)mv · latest status →orders_latestReplacingMergeTreeorder_id → newest status2SummingMergeTreemerge: add the values22025-06-15desktop1,240.002025-06-15desktop610.002025-06-15desktop1,850.00after merge3AggregatingMergeTreemerge: combine partial states32025-06-15uniq {u1, u2}2025-06-15uniq {u2, u3}2025-06-15{u1, u2, u3} → 3after merge4ReplacingMergeTreemerge: keep the newest row4order 42paid · v1order 42shipped · v2order 42shipped · v2after merge5Query time5SELECT day, sum(revenue)FROM revenue_daily GROUP BY daySELECT day, uniqMerge(users)FROM users_daily GROUP BY daySELECT status FROM orders_latestFINAL WHERE order_id = 42merges run in the background, so queriesstill aggregate, over a few rowsRows read per querylog scaleevents, raw13.6 B rowsrevenue_daily≈ 450 rowsusers_daily≈ 90 rowscost: every insert also writes every view
Illustrative exampleOne insert feeds three views; each table engine merges its rows in the background. Values are illustrative.