Learning Objectives
- Explain why ClickHouse's primary index is sparse — one entry per granule, not per row — rather than a per-row index like a B-tree
- Predict, given a table's ORDER BY and a query's WHERE clause, whether the primary index will narrow the scan or not
- Explain why a query whose filter doesn't match the ORDER BY prefix is usually still fast in ClickHouse, unlike an unindexed filter in a row-oriented OLTP database
- Read EXPLAIN indexes = 1 output to see how many parts and granules a query actually reads
Why This Matters
If you've worked with Postgres or MySQL, you already carry a rule in your head: filter on a column with no index, and the database resorts to a full table scan — often the difference between a query that returns instantly and one that times out. That rule is so reliable in OLTP databases that it's easy to import it wholesale into ClickHouse, and then either panic the first time you write a query that doesn't match the table's ORDER BY, or over-engineer a pile of narrow, single-purpose tables to avoid ever triggering it. Neither reaction is warranted. The rule you learned isn't wrong — it's scoped to a kind of index ClickHouse doesn't use. Once you see why a "wrong" filter is usually fine here, you stop fighting a threat that doesn't actually exist in this system, and you can spend that attention on the trade-off that actually does matter.
The B-tree instinct, and why it doesn't transfer
In a row-oriented OLTP database, a B-tree index maps a column value to the exact row (or row pointer) that holds it. That's what makes a lookup on an indexed column fast: the database walks a small tree and lands on precisely the row it needs, touching almost nothing else. Filter on a column with no such index, and there's no shortcut — the engine has to walk every row, checking each one, and for large tables that's slow enough to notice immediately.
The loading-data lesson earlier in this module already showed you the mechanism: every INSERT sorts its block by the table's ORDER BY expression before writing a part. That same ORDER BY expression doubles as the table's primary key — unless a table specifies a separate PRIMARY KEY, ClickHouse uses the sorting key as the primary key directly. It's natural to assume that primary key behaves like the B-tree you already know: an exact structure for finding rows. It doesn't. ClickHouse's primary index is built on a completely different idea, and that difference is exactly why a "wrong" filter doesn't blow up the way a missing B-tree index does.
An index over granules, not rows
Instead of indexing individual rows, ClickHouse divides each part into granules — fixed-size blocks of consecutive rows in the part's sort order, 8,192 rows by default. The primary index stores exactly one entry per granule: the primary key value of that granule's first row. Not one entry per row. One entry for every 8,192 rows.
That's what makes it a sparse primary index: it's deliberately coarse. It was never built to answer "which row has this value" — it can't, on its own; a sparse index only ever tells you which row a granule starts at. What it's built to answer is a cheaper, coarser question: "which granules could possibly contain a match, so I can skip the rest?" And because it's one small entry per 8,192 rows rather than one per row, the whole index for a table with hundreds of millions of rows stays small enough to sit comfortably in memory — the tree you'd need to index every individual row at that scale would not.
This coarseness has a direct, measurable consequence even in the best case, when your filter matches the ORDER BY prefix perfectly: the index can only ever narrow the search down to whole granules, never to a single row within one. Reading a matched range of the primary key can pull in up to twice a granule's worth of extra rows that don't actually satisfy the filter, simply because the index doesn't have the resolution to draw the line more precisely than "somewhere in this granule." ClickHouse's primary index was never a precision instrument. It's a coarse pre-filter, and once you see that, the "wrong index" case stops looking like a special failure — it's just the same coarse tool doing less filtering than usual.
Why "every granule read" still isn't a disaster
Here's the part that doesn't transfer from the OLTP world. When a row-oriented database gives up on an index, it falls back to scanning every row, in full — every column of every row, because that's the physical unit the storage format hands back. That's genuinely expensive, and it's why the "missing index" rule is so unforgiving there.
When ClickHouse's primary index can't narrow anything — because your filter has nothing to do with the ORDER BY prefix — it still isn't scanning full rows. It's still a columnar engine underneath: reading user_id for every granule means reading only the user_id column file (plus whatever other columns the query actually selects), not the other twenty columns sitting untouched in their own files. And it still processes that column in vectorized blocks, the same block-at-a-time execution style from Module 1. "The primary index found nothing to skip" and "the query has to inspect every row's full contents" are two different failure modes — ClickHouse only ever has the first one. That's the whole reason a "wrong" filter here tends to land on "slower, but fine" instead of "catastrophically slow."
-- events is ORDER BY (event_type, event_time), 5,000 granules total
EXPLAIN indexes = 1
SELECT count()
FROM events
WHERE event_type = 'purchase' AND event_time > '2024-06-01';
"Indexes": [
{
"Type": "PrimaryKey",
"Keys": ["event_type", "event_time"],
"Condition": "and((event_type in ['purchase', 'purchase']), (event_time in (2024-06-01, +inf)))",
"Parts": "3/12",
"Granules": "40/5000"
}
]
EXPLAIN indexes = 1
SELECT count()
FROM events
WHERE user_id = 42;
"Indexes": [
{
"Type": "PrimaryKey",
"Keys": ["event_type", "event_time"],
"Parts": "12/12",
"Granules": "5000/5000"
}
]
Reading these two side by side is the actual diagnostic skill this lesson is building: Granules: "40/5000" means the primary index ruled out 4,960 of 5,000 granules before ClickHouse read a single value. Granules: "5000/5000" means it ruled out nothing — every granule gets read — but, per the mechanic above, "read" here still means "read the user_id column only," not "read every column of every row." EXPLAIN is how you stop guessing which case you're in and just look.
Common Misconception
"If my WHERE clause doesn't match the table's ORDER BY, I need to add an index — like I would in Postgres — or the query will time out."
The instinct is reasonable; the model behind it is the wrong one. There's no separate "add an index or suffer" cliff in ClickHouse the way there is with a B-tree-indexed row store. When your filter doesn't match the ORDER BY prefix, the primary index simply can't skip any granules — every granule gets read — but "read" still means a columnar, vectorized read of only the columns the query touches, not a full-row scan. That's usually still fast enough not to notice, especially at the data volumes this course has been working with so far. It stops being "usually fine" and starts being genuinely costly as the table grows into the billions of rows and the query touches many columns — at which point choosing a better ORDER BY, or another tool covered later in this course, actually matters. The lesson here isn't "indexes don't matter in ClickHouse." It's that the failure mode for an unmatched filter is degraded skipping, not the row-store cliff you're used to — and the two call for different reactions.
Predict, Then Verify
events has 50,000,000 rows, ORDER BY (event_type, event_time), and the default index_granularity of 8,192 — roughly 6,100 granules. Two queries run against it:
- Query A:
WHERE event_type = 'refund' AND event_time > '2024-01-01' - Query B:
WHERE session_id = 'abc123'
For each query, predict: will EXPLAIN indexes = 1 show the PrimaryKey index narrowing the granule count, or reading all ~6,100 granules? And separately: does that mean Query B is doomed to be unreasonably slow?
Reveal the answer →
Query A narrows dramatically. event_type is the leading column of ORDER BY, so the primary index can rule out every granule that doesn't contain 'refund' rows, and event_time narrows further within that. Expect something like Granules: "50/6100" — the vast majority of the table is skipped before any column is even opened.
Query B reads all ~6,100 granules — session_id has no relationship to (event_type, event_time), so the primary index has nothing to rule out. Granules: "6100/6100".
That doesn't mean Query B is doomed. It means ClickHouse reads the session_id column (and whatever else the query selects) across every granule — one column, in vectorized blocks, not full rows — instead of a narrowed slice of it. On 50 million rows that's meaningfully more work than Query A's, and it will get slower as the table keeps growing, since there's no skipping to absorb that growth. But "more work than the narrowed case" and "unreasonably slow" are not the same claim — for a single column on a table this size, it's still a query most learners would call fast, just not as fast as Query A.
Summary
ClickHouse's primary index isn't a B-tree and doesn't try to be one: it's a sparse index with one entry per granule (8,192 rows, by default) rather than one per row, built to rule out whole granules cheaply rather than to pinpoint individual rows — which is also why even a perfectly matched filter still reads a little more than the exact answer requires. When a query's filter matches the ORDER BY prefix, the index narrows the read to a small slice of the table's granules; when it doesn't, every granule gets read, but "every granule" still means a columnar, vectorized read of only the needed columns, not a full-row scan the way an unindexed OLTP query would perform. That's why a "wrong" filter here tends to cost you some skipping, not a cliff — and EXPLAIN indexes = 1, reporting exactly how many parts and granules a query touched, is how you check which situation you're actually in instead of guessing from instinct built on a different kind of database.
Quiz
1. Why can't ClickHouse's primary index point directly to one specific row, the way a B-tree index in Postgres or MySQL can?
2. A table is ORDER BY (event_type, event_time). Which query's WHERE clause lets the primary index narrow the granules read?
3. EXPLAIN indexes = 1 on a query shows "Granules": "6100/6100" for the PrimaryKey index. What does that tell you, and what does it NOT tell you?
4. A learner used to Postgres says: "This ClickHouse query filters on a column that's not in the ORDER BY, so it's basically an unindexed query — it's going to be just as slow as a missing-index scan back home." What's the flaw in that reasoning?
5. Why does even a perfectly matched primary-key filter (e.g., an exact match on the leading ORDER BY column) still read somewhat more data than the exact rows that satisfy it?