skip to content

Partitioning & Indexing

ORDER BY defines the physical sort and the sparse primary index, the partition key defines which parts can be dropped whole, and skipping indices prune granules further. Explaining how a query narrows from parts down to granules is the core ClickHouse interview question.

part ofClickHouseoverview, primer and where to startread it →
on this pageshow

questions

7

In a ClickHouse MergeTree table, how do PRIMARY KEY and ORDER BY differ?

level: middleimportance: must knowfreq 75%

answer

  1. two keys, two different jobs
  2. one sorts rows, one builds the index
  3. the index key must be a prefix
  4. collapsing engines need a wider sort key
  5. omit PRIMARY KEY and it copies ORDER BY

basics

~20 s

In ClickHouse MergeTree, ORDER BY sets the physical sort order of rows inside every part; PRIMARY KEY only defines the sparse index and must be a prefix of ORDER BY. If PRIMARY KEY is omitted it equals ORDER BY.

solid answer

~50 s

`ORDER BY` is the sorting key: rows in every data part are written sorted by that expression list. `PRIMARY KEY` is the indexing key: ClickHouse stores one index entry per granule holding the primary-key values of that granule's first row. The primary key must be a **prefix** of the sorting key, and when you leave it out it defaults to the whole `ORDER BY`. You separate them when the sort order needs more columns than the index does — typically in `ReplacingMergeTree` or `AggregatingMergeTree`, where `ORDER BY` must contain every identifying column so rows collapse correctly, but you don't want all of them in an index that is held in memory. A shorter `PRIMARY KEY` also lets you later append columns to `ORDER BY` with `ALTER TABLE ... MODIFY ORDER BY`, which is not possible if the two are identical.

code

sql · 11 lines
sql
CREATE TABLE events
(
    tenant_id  UInt32,
    event_date Date,
    user_id    UInt64,
    payload    String
)
ENGINE = ReplacingMergeTree
PARTITION BY toYYYYMM(event_date)
PRIMARY KEY (tenant_id, event_date)
ORDER BY (tenant_id, event_date, user_id);

go deeper

for a junior

Be able to say that a MergeTree table stores its rows sorted by ORDER BY, and that PRIMARY KEY, when written separately, must be the first few columns of that same list.

for a middle

Explain the sparse index: one entry per granule holding the first row's key values, which is why the index key has to be a prefix of the sort key. Know that PRIMARY KEY is not a uniqueness constraint.

for a senior

Show judgment about when to split them — collapsing engines needing a wide identity, index memory across many parts, and leaving room for ALTER TABLE MODIFY ORDER BY later.

for a principal

Own the fact that the key is effectively permanent: changing leading columns means a rebuild and swap. Frame key choice as a schema-lifecycle decision reviewed against the query mix, not a one-off DDL detail.

## Two clauses, two jobs A `MergeTree` table declares both a sorting key and an indexing key, and interview candidates routinely conflate them because most DDL only writes one of them. - `ORDER BY (a, b, c)` — the **sorting key**. Every data part written to disk contains its rows physically sorted by `(a, b, c)`. This is what makes column data compress well (neighbouring values are similar) and what makes range reads sequential. It is also what deduplication/aggregation engines use as their row identity. - `PRIMARY KEY (a, b)` — the **indexing key**. ClickHouse builds a *sparse* index: one entry per granule (by default one entry per 8192 rows), storing the primary-key values of the first row of that granule. That index is small enough to be held in memory and is binary-searched at query time to pick a range of granules to read. If you write only `ORDER BY`, the primary key silently becomes the same expression list. If you write both, ClickHouse enforces that `PRIMARY KEY` is a **prefix** of `ORDER BY` — `PRIMARY KEY (a, b)` with `ORDER BY (a, b, c)` is legal; `PRIMARY KEY (b)` with `ORDER BY (a, b)` is not. ## Why the prefix rule exists The index entry for a granule records the key values of the granule's *first* row. That is only usable for skipping if the rows are sorted by exactly that expression, in exactly that order. If the index key were not a prefix of the sort key, the first row's values would tell you nothing about the range covered by the granule, and pruning would be unsound. The same rule is why the index prunes only on a leading run of key columns: a filter on `c` alone cannot narrow granules, because `c` is only sorted *within* equal `(a, b)`. ## When to declare them separately Three real reasons: 1. **Collapsing engines.** `ReplacingMergeTree` and `AggregatingMergeTree` treat the whole `ORDER BY` as the row's identity. If dedup identity is `(tenant_id, entity_id, version_source)` but queries only ever filter on `tenant_id, entity_id`, put the first two in `PRIMARY KEY` and all three in `ORDER BY`. You keep correct collapsing without paying for a wider index. 2. **Index memory.** The primary index is resident per part; a wide key over many parts costs RAM and slows the binary search for no pruning benefit. Trimming trailing columns that no query filters on is free. 3. **Evolvability.** `ALTER TABLE ... MODIFY ORDER BY` can *append* columns to the sorting key only if the primary key stays unchanged as a prefix. Tables where `PRIMARY KEY` was never separated out have no room to grow the sort key later without rebuilding the table. ```sql CREATE TABLE events ( tenant_id UInt32, event_date Date, user_id UInt64, payload String ) ENGINE = ReplacingMergeTree PARTITION BY toYYYYMM(event_date) PRIMARY KEY (tenant_id, event_date) ORDER BY (tenant_id, event_date, user_id); ``` Here the index is two columns wide; dedup identity is three columns wide; the on-disk sort is by all three. ## What neither clause is Neither is a uniqueness constraint. ClickHouse does not enforce uniqueness on the primary key — two rows with identical key values coexist happily in a plain `MergeTree`, and even the collapsing engines only merge duplicates eventually and per part. Coming from PostgreSQL or MySQL, this is the single biggest surprise: `PRIMARY KEY` here means "the thing the sparse index is built on", nothing more. Neither is a B-tree either. There is no per-row index entry to look a row up by; a point lookup on the key still reads at least one whole granule. ## Changing them later You cannot change `ORDER BY` arbitrarily — only append columns, and only when the primary key is a strict prefix that stays put. Changing the leading columns means creating a new table with the desired key and copying the data (`INSERT INTO new SELECT * FROM old`), then swapping names with `EXCHANGE TABLES`. Because of that, key choice is close to irreversible in practice, which is why interviewers press on it. ## Interview traps Saying "the primary key is the unique identifier" or "ORDER BY is just for ORDER BY queries" both signal that the candidate has only used `MergeTree` from a template. The strong answer names the prefix rule, the sparse-index-per-granule model, and one concrete case where separating the two clauses pays.

  • Does ClickHouse enforce uniqueness on a MergeTree PRIMARY KEY?
    No. `PRIMARY KEY` names the columns the sparse index is built on; duplicate key values are stored without complaint. Collapsing engines such as `ReplacingMergeTree` remove duplicates only during background merges, and only within a single partition, so a `SELECT` can still see duplicates until a merge happens — which is why `FINAL` or an aggregate-based rewrite exists.
  • Which parts of ORDER BY can you change on an existing table?
    Only appended columns, via `ALTER TABLE ... MODIFY ORDER BY`, and only when the declared `PRIMARY KEY` remains an unchanged prefix. Changing a leading column means building a new table with the new key, copying data with `INSERT INTO ... SELECT`, and swapping with `EXCHANGE TABLES`. Plan the key as if it were permanent.
  • Why does a narrower PRIMARY KEY than ORDER BY save resources?
    The sparse index holds one entry per granule per part and is kept in memory. Trailing key columns no query filters on inflate every entry, costing RAM across thousands of parts and slowing the binary search, while adding no pruning power — the index can only narrow on a leading run of columns anyway.

saying these in an interview costs you the question

  • Calling PRIMARY KEY a uniqueness constraint like in PostgreSQL
  • Claiming ClickHouse builds a B-tree over the primary key
  • Thinking ORDER BY only affects queries that sort
  • Believing PRIMARY KEY can be any subset of ORDER BY
  • Assuming ORDER BY can be freely changed later with ALTER

context

open as a page

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

level: middleimportance: must knowfreq 72%

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.

open as a page

In a ClickHouse MergeTree table, what does the PARTITION BY clause actually do?

level: juniorimportance: should knowfreq 65%

basics

~20 s

PARTITION BY splits a ClickHouse MergeTree table into independent groups of parts, usually by month. It enables dropping or detaching data as a whole unit, per-partition TTL, and coarse pruning — it is a data-management tool, not a substitute for the sorting key.

open as a page

In ClickHouse, how do you choose the column order for a MergeTree ORDER BY key?

level: seniorimportance: should knowfreq 58%

basics

~20 s

Lead with the column almost every query filters on, then order the rest so that low-cardinality columns come before high-cardinality ones. Only a leading prefix of the key prunes granules, so the first column decides how much data a typical query reads.

open as a page

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

level: seniorimportance: should knowfreq 52%

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.

open as a page

A ClickHouse table partitioned by toYYYYMMDD(event_time) now rejects inserts with "Too many parts" — why?

level: seniorimportance: should knowfreq 48%

basics

~20 s

Each insert creates at least one part per partition it touches, and merges never combine parts across partitions. A daily partition key multiplies the active partitions, so parts accumulate faster than background merges can consolidate them and ClickHouse throttles then rejects inserts.

open as a page

When would you lower index_granularity below the ClickHouse default of 8192 rows?

level: seniorimportance: nice to knowfreq 32%

basics

~20 s

Lower it only when queries are highly selective on the sorting key and the 8192-row granule is the dominant read cost — typically narrow tables serving near-point lookups. The price is a larger in-memory primary index, more marks, and worse compression on wide scans.

open as a page