skip to content

When does a ClickHouse data-skipping index such as minmax or bloom_filter actually help?

level: seniorimportance: should knowfreq 52%

answer

  1. it filters blocks, it never locates rows
  2. the unit is granules, not rows
  3. scattered values defeat every type
  4. ranges need min/max, equality can use a filter
  5. existing parts need it materialized

basics

~20 s

A ClickHouse skipping index helps only when the indexed column's values are physically clustered under the table's sorting key. It stores summaries per block of granules and drops blocks that cannot match; on values scattered evenly across the table it eliminates nothing and only adds cost.

solid answer

~50 s

A data-skipping index is declared in the DDL as `INDEX name expr TYPE minmax GRANULARITY n`, and it stores a small summary — min/max, an explicit value set, or a Bloom filter — for each group of `n` index granules. At query time, after primary-index analysis, ClickHouse evaluates the predicate against those summaries and drops granule blocks that provably cannot match. The decisive condition is **correlation with the sort order**: `minmax` on a monotonically increasing timestamp works beautifully because each block covers a tight range, while `minmax` on a randomly distributed column gives every block nearly the full range and prunes nothing. `set(n)` fits low-cardinality columns whose distinct values per block stay under `n`; `bloom_filter` fits higher-cardinality equality and `IN` predicates, with `tokenbf_v1`/`ngrambf_v1` for substring search. It is a filter, never a lookup structure — a skipping index cannot rescue a table whose sorting key is wrong.

code

sql · 6 lines
sql
ALTER TABLE events
    ADD INDEX idx_status status TYPE set(20) GRANULARITY 4,
    ADD INDEX idx_uid user_id TYPE bloom_filter(0.01) GRANULARITY 1;

-- existing parts are not covered until this runs
ALTER TABLE events MATERIALIZE INDEX idx_status;

go deeper

for a junior

Recall that ClickHouse can store small per-block summaries — min/max, value sets, Bloom filters — and skip blocks that cannot match, and that they are declared in the table DDL.

for a middle

Explain what each type stores, that GRANULARITY counts index granules rather than rows, and why an uncorrelated column defeats all of them.

for a senior

Decide from data layout whether one will pay off, materialize it over existing parts, verify with EXPLAIN indexes = 1, and remove indexes that cost writes without pruning.

for a principal

Weigh the ingest-side write amplification of secondary structures against the alternative of a second sorted copy of the data, and set a policy that indexes are added on measured evidence, not on request.

## What a skipping index is ClickHouse has exactly one ordering index — the sparse primary index over the sorting key. Data-skipping indexes are the secondary mechanism: small per-block summaries that let the engine discard granules a predicate cannot match, *after* partition pruning and primary-index analysis have already run. They are declared inside the table definition: ```sql ALTER TABLE events ADD INDEX idx_status status TYPE set(20) GRANULARITY 4, ADD INDEX idx_uid user_id TYPE bloom_filter(0.01) GRANULARITY 1; ``` `GRANULARITY n` is the part everyone gets wrong on first contact: it is **not** rows. It is the number of index granules (blocks of `index_granularity` rows, 8192 by default) covered by one skipping-index entry. `GRANULARITY 4` at the default row granularity means one summary per 32,768 rows. A smaller value gives finer pruning and a larger index; a larger value gives a tiny index that prunes coarsely. Adding an index to an existing table only affects newly written parts. Existing parts need `ALTER TABLE ... MATERIALIZE INDEX idx_name`, which rewrites index files in the background. ## The types and what they fit - **`minmax`** — stores the minimum and maximum of the expression per block. Cheapest of all, and the right choice for anything correlated with the sort order: an ingest timestamp when the table is sorted by date, a monotonic sequence id, a derived coarse bucket. Also handles range predicates, which the other types cannot. - **`set(max_rows)`** — stores up to `max_rows` distinct values per block; if a block exceeds that, the index gives up for that block and it is always read. Good for low-cardinality categorical columns — status, country, event type — used with `=` or `IN`. - **`bloom_filter([false_positive_rate])`** — a probabilistic membership filter per block. Handles equality and `IN` on higher-cardinality columns where a `set` would overflow. False positives cost you an unnecessary block read; false negatives are impossible, so results stay correct. - **`tokenbf_v1(...)` and `ngrambf_v1(...)`** — Bloom filters over tokens or n-grams of a string, for `LIKE '%needle%'` and `hasToken` style predicates on text columns. ## The condition that decides everything: clustering A per-block summary can only exclude a block if the block's values are *narrow* relative to the predicate. That is a property of the physical layout, not of the index type. If `user_id` values are shuffled uniformly across the table because the sorting key is `(tenant_id, event_date)` and users interleave freely, then almost every 32,768-row block contains at least one row for almost any popular user id — a Bloom filter over that block says "maybe" every time, and you read the whole table plus the index. The same index on the same column becomes powerful the moment the data is clustered: if events for a user arrive in bursts, or the sorting key ends with something correlated with `user_id`, blocks become homogeneous and the filter starts excluding most of them. So the honest checklist before adding one is: 1. Is the predicate column outside the sorting-key prefix (otherwise the primary index already handles it)? 2. Are its values physically clustered under the current sort order? 3. Is the predicate shape supported by the type you're choosing — ranges need `minmax`, equality can use `set` or `bloom_filter`? If the answer to 2 is no, the correct fix is a different sorting key or a second sorted copy of the data, not an index. ## Measuring instead of guessing `EXPLAIN indexes = 1` prints each index-analysis step with the parts and granules surviving it, so you can see directly whether the skipping index removed anything beyond what the primary index already removed. `system.data_skipping_indices` reports each declared index with its type, expression, granularity and on-disk size, which is how you find indexes that cost storage and contribute nothing. During investigation, the setting `force_data_skipping_indices` makes a query fail unless the named index is usable, which is a blunt but effective way to catch a query that silently stopped using one. ## Costs Skipping indexes are not free. Every insert computes and writes their summaries, merges rewrite them, they occupy disk, and index analysis itself takes time proportional to the number of index blocks. A Bloom filter with a very low false-positive rate over a high-cardinality column can grow large. Several unused indexes on a hot ingest table are pure write amplification. The default assumption should be zero skipping indexes, each one added because a specific query shape measurably improved. ## Interview traps The two answers that end the topic are "it's like a secondary index, so filters on that column become fast" and "add a Bloom filter on any column people filter by". Both miss the clustering condition. The strong answer says: summaries per block of granules, pruning only when values are clustered under the sort order, `GRANULARITY` counted in granules not rows, and measurement with `EXPLAIN indexes = 1` before and after.

  • What does GRANULARITY mean in a ClickHouse skipping-index declaration?
    It is the number of index granules covered by one index entry, not a row count. With the default index_granularity of 8192 rows, `GRANULARITY 4` means one summary per 32,768 rows. Lower values prune more finely at the cost of a larger index and slower analysis; higher values give a tiny index with coarse pruning.
  • You add a skipping index to a large existing table and nothing gets faster. What is the first thing to check?
    Whether it exists in the old parts. `ADD INDEX` applies only to parts written afterwards; existing parts need `ALTER TABLE ... MATERIALIZE INDEX`, which rebuilds them in the background. After that, confirm with `EXPLAIN indexes = 1` that the index step actually removes granules, and if it does not, suspect that the column's values are not clustered under the sort order.
  • Why can a bloom_filter skipping index never return wrong query results?
    Bloom filters have false positives but no false negatives. A block whose filter says the value is absent genuinely does not contain it and is safely skipped; a block that says 'maybe' is read and then filtered row by row. The worst case is wasted I/O, never a missing row.

It is like the min and max price printed on the outside of a crate of mixed goods: useful if each crate was packed by price, useless if every crate holds one of everything.

saying these in an interview costs you the question

  • Calling it a secondary index that locates rows directly
  • Reading GRANULARITY as a number of rows
  • Adding a bloom_filter to any frequently filtered column
  • Expecting a skipping index to fix a badly chosen sorting key
  • Forgetting existing parts need MATERIALIZE INDEX

context