learning.lab
Storage Engine· 7/33

ORDER BY & Primary Keys

The sparse index

ORDER BY is the single most consequential decision in a MergeTree schema. It defines the physical sort order of every row on disk, and — unless you say otherwise — doubles as the table's primary key.

Not a B-Tree — a sparse index

Postgres's B-Tree index has one entry per row. ClickHouse tables can hold tens of billions of rows, so indexing every single one would make the index itself enormous. Instead, rows are grouped into granules — 8192 rows by default — and the primary index stores exactly one entry per granule: the primary key value of its first row. That's the "sparse" part.

ORDER BY (user_id, event_time) — granule size shrunk to 3 for this example
mark #0
user_idtimeevent
10210:01click
10210:03view
10210:07click
mark #1
user_idtimeevent
20409:58click
20410:02purchase
20410:12view
mark #2
user_idtimeevent
31010:00click
31010:04click
31010:09view

How a lookup actually works

For WHERE user_id = 204, ClickHouse doesn't scan rows — it binary-searches the small in-memory array of marks to find which granule could contain 204, then reads and decompresses only that granule's columns (and its immediate neighbors, since the target may straddle a boundary). On a real table that's the difference between reading three rows and reading nine — at production scale, between reading megabytes and reading gigabytes.

try a lookup — WHERE user_id = ?
WHERE user_id =
user_idtimeevent
10210:01click
10210:03view
10210:07click
user_idtimeevent
20409:58click
20410:02purchase
20410:12view
skipped
user_idtimeevent
31010:00click
31010:04click
31010:09view
skipped

Marks put 102 inside granule #0 — that's the only one opened. 6 of 9 rows are never read.

CREATE TABLE events
(
    user_id    UInt64,
    event_time DateTime,
    event      LowCardinality(String)
)
ENGINE = MergeTree
ORDER BY (user_id, event_time);

Column order in ORDER BY matters

Rows are sorted by the first column, then the second breaks ties within it, and so on — exactly like a compound index. A filter on the first column can binary-search straight to the right granules. A filter on only the second column gets no such benefit, because rows with a matching event_time are scattered across every user_id group.

  • Put the column your queries filter on most often first.
  • Prefer lower-cardinality columns before higher-cardinality ones when both are filtered together — it keeps runs of identical values longer, which compresses and skips better.
  • The primary key doesn't need to be unique — unlike Postgres, duplicate keys are completely normal.
Common mistake
Changing ORDER BY after a table already has data isn't a metadata change — existing parts were physically written in the old order. In practice this means creating a new table with the desired order and re-inserting, not ALTER TABLE.
Under the hood
index_granularity (default 8192) controls granule size. Smaller granules mean a more precise index and less wasted scanning, at the cost of a larger index held in memory — rarely worth tuning unless you have unusually wide or narrow rows.