skip to content

How does ClickHouse's sparse primary index use granules to skip data in a query?

level: middleimportance: must knowfreq 72%

answer

  1. the index does not point at rows
  2. 8192 rows is the default block
  3. one entry per block, first row's values
  4. binary search yields a granule range
  5. only a leading run of key columns prunes

basics

~20 s

ClickHouse splits each part into granules of index_granularity rows (8192 by default) and stores one index entry per granule holding its first row's key values. A filter on a leading key column binary-searches those entries and reads only the matching granules.

solid answer

~50 s

Inside a data part, rows are sorted by the sorting key and cut into **granules** — by default 8192 rows each. The primary index file stores one entry per granule: the primary-key values of that granule's first row. Because the rows are sorted, consecutive entries bound each granule's value range, so a predicate on a leading key column becomes a binary search that yields a contiguous range of granules. Mark files then translate granule numbers into byte offsets in each column file, and only those column ranges are read and decompressed. The index is *sparse*, so it never points at a row: a lookup for a single key value still reads a whole granule, at least 8192 rows. It is also per part — every active part is analysed independently, after partition pruning has already discarded whole partitions.

code

sql · 4 lines
sql
EXPLAIN indexes = 1
SELECT count()
FROM events
WHERE tenant_id = 7 AND event_date = '2025-03-01';

go deeper

for a junior

Recall that ClickHouse indexes blocks of rows, not individual rows, and that the default block is 8192 rows. Know that filtering on the first sorting-key column is what makes queries fast.

for a middle

Walk the mechanism end to end: sorted rows, granules, one index entry per granule, binary search to a granule range, marks to byte offsets. Explain why only a leading key prefix prunes.

for a senior

Diagnose in production: read EXPLAIN indexes = 1 for surviving granules, spot predicates that touch no key prefix, and recognise when part fragmentation is destroying selectivity.

for a principal

Own the design implication — sort order is the only real index, and it is effectively permanent. Decide when a second sorted copy of the data is worth its storage and ingest cost versus accepting full scans.

## Granules: the unit of reading A `MergeTree` part stores each column in its own compressed file, with rows in sorting-key order. Those rows are logically cut into **granules**, controlled by the table setting `index_granularity`, whose default is 8192 rows. A granule is the smallest amount of data ClickHouse will read from a column file — you cannot fetch one row, only the granule containing it. Two structures per part make this work: - The **primary index** (`primary.idx`): one entry per granule, containing the primary-key column values of the granule's *first* row. It is small — a table with a billion rows and 8192-row granules has roughly 122,000 entries per full part — and is held in memory. - The **mark files** (one per column): for each granule, the offset of the compressed block and the offset inside it. This is what converts "I want granules 40–52" into actual byte ranges in every column file the query touches. ## How a query narrows down For `WHERE tenant_id = 7 AND event_date = '2025-03-01'` on a table with `ORDER BY (tenant_id, event_date, user_id)`, execution narrows in stages: 1. **Partition pruning.** If a partition key is declared, whole partitions whose min/max cannot satisfy the predicate are dropped before any part is opened. Each part also stores min/max values for the partition-key columns, so a filter on the underlying column prunes even when the partition expression is a function of it. 2. **Primary index analysis, per part.** The condition is turned into a range over the key. Because index entries are sorted, ClickHouse binary-searches for the first and last granule whose range can intersect the condition, producing a set of granule ranges. 3. **Skipping index analysis.** Any declared data-skipping indexes then remove further granules from that set. 4. **Reading.** Marks convert surviving granules to byte ranges; only those blocks of only the referenced columns are read and decompressed, then filtered row by row — the granule almost always contains non-matching rows too, and the filter step removes them. ## Sparse, not dense — and why that matters A B-tree in an OLTP engine stores one entry per row and can fetch exactly the row you asked for. ClickHouse deliberately does not: an entry per row over billions of rows would be larger than the data it indexes and would defeat the compression that makes the columnar layout cheap. The consequence is a hard floor on read amplification. A query for a single user id, when `user_id` is the leading key column, still reads at least 8192 rows' worth of every column it selects. ClickHouse is not a key-value store, and "we'll use it for point lookups" is the classic misuse. The corollary is that the index only helps on a **leading run** of key columns. With `ORDER BY (tenant_id, event_date, user_id)`, a query filtering only on `user_id` cannot use the index at all: `user_id` is sorted only within equal `(tenant_id, event_date)`, so every granule's range covers the whole domain of `user_id`. The plan will select every granule of every part. Filtering on `tenant_id` alone prunes well; filtering on `tenant_id` and `event_date` prunes better. ## Reading the effect `EXPLAIN indexes = 1` in front of the query prints the index-analysis steps and how many parts and granules survive each of them — that is the direct evidence that pruning happened, rather than guessing from wall-clock time. Comparing the granules selected against the table's total is the diagnosis: if the number is essentially everything, the predicate does not touch a key prefix. ```sql EXPLAIN indexes = 1 SELECT count() FROM events WHERE tenant_id = 7 AND event_date = '2025-03-01'; ``` ## Practical consequences - **Sort order is the index.** There is no way to add a second primary index to a table; if a second access path is genuinely needed, the standard answers are a data-skipping index, or a second copy of the data sorted the other way. - **Cardinality of the leading column decides everything.** A leading column with two distinct values splits the table into two ranges — pruning is nearly useless. A leading column that is already the natural filter (tenant, customer, date) is what makes the pattern work. - **Many small parts weaken it.** Index analysis runs per part, and each part contributes at least one granule of reading if it cannot be excluded outright, so the same logical data spread over thousands of parts is read far less selectively than over a handful of large ones. - **The granule floor is tunable but rarely tuned.** `index_granularity` can be lowered per table to reduce read amplification for very selective lookups, at the cost of a bigger in-memory index and more marks. ## Interview traps Calling it "like a B-tree", claiming the index locates rows, or asserting that any indexed column prunes regardless of position are the three answers that end the topic. The strong answer walks parts → granules → marks and states the 8192-row floor explicitly.

  • Why can a query filtering only on the third ORDER BY column not use the primary index?
    Rows are sorted by the whole key, so the third column is ordered only within equal values of the first two. Every granule's range for that column therefore spans essentially the full domain, and no granule can be excluded. The index selects everything; only a skipping index, a different sort order, or a second differently-sorted table helps.
  • If a table has a billion rows, roughly how many primary index entries does it have?
    About a billion divided by index_granularity — roughly 122,000 entries at the 8192-row default, spread across the table's active parts. That is small enough to keep resident in memory and binary-search cheaply, which is exactly the trade the sparse design buys by giving up per-row addressing.
  • How do you confirm from a plan that primary-index pruning actually happened?
    Run the query behind `EXPLAIN indexes = 1`. It prints the index-analysis steps — partition, primary key, and any skipping indexes — with the number of parts and granules surviving each. If the surviving granule count is close to the table total, the predicate is not hitting a key prefix.

It works like the thumb index of a printed dictionary: the tabs tell you which page-block a word falls in, not which line. You still read the whole block to find the entry.

saying these in an interview costs you the question

  • Describing the primary index as a B-tree over rows
  • Claiming a point lookup reads exactly one row
  • Thinking any column in ORDER BY prunes regardless of position
  • Assuming the index is global rather than per part
  • Saying granules are physical files rather than row blocks

context