Learning Objectives
- Explain why only a left-anchored prefix of ORDER BY's columns can narrow a query's granule scan, not any subset of them
- Predict, given an ORDER BY expression and a set of candidate query filters, which filters let the primary index skip granules and which don't
- Explain the difference between a table's sorting key (ORDER BY) and its primary key, and why you'd declare a PRIMARY KEY that's a shorter prefix of ORDER BY
- Explain what ORDER BY columns beyond the primary key still do, even though the sparse index has no entries for them
Why This Matters
The last lesson in Module 2 showed you that a filter matching the leading column of ORDER BY narrows the granules read, and a filter on some unrelated column doesn't. That's true, but it's an incomplete rule — it only ever showed you a two-column ORDER BY and only ever tested the first column against the second. Real tables have three, four, five columns in their sorting key, and the question you'll actually face is messier: which combination of filtered columns narrows the scan, and which doesn't? Get this wrong and you'll either write an ORDER BY that helps almost none of your real queries, or worse, assume a filter is being narrowed when it isn't and get blindsided the first time that query runs slow at scale. There's a second piece hiding behind the same idea: ORDER BY and "primary key" aren't always the exact same thing, and knowing when and why they diverge is what the word "(Mostly)" in this lesson's title is doing.
The rule, stated precisely: a prefix, not a set
You already know the sparse primary index is built from ORDER BY, one entry per granule, and that it can only rule out granules whose values fall outside a query's filter. What wasn't yet spelled out is exactly which filters that applies to when ORDER BY has more than two columns.
Think of it the way a phone book sorted by (last name, first name) works. You can jump straight to every "Ramirez," and within that, straight to every "Ramirez, John." But you cannot jump straight to every "John" regardless of last name — the book just isn't organized that way. Once you fix "Ramirez," the "John"s inside it are grouped together; until you fix a last name, the first names are scattered across the entire book in no useful order at all.
That's the whole rule, generalized: the primary index reliably narrows a scan using a left-anchored, contiguous prefix of ORDER BY's columns, starting from the first. Not "any of the columns in ORDER BY," not "as many as show up in the WHERE clause." Once a column in that sequence is left unconstrained, don't count on any column after it to add further narrowing — ClickHouse does have a fallback search for that case, but its effectiveness depends unpredictably on the data (specifically, how many distinct values the skipped column has), so it isn't something to design a table around. For the purposes of designing an ORDER BY, treat a gap in the prefix as "no dependable narrowing beyond it."
CREATE TABLE requests
(
org_id UInt64,
endpoint String,
ts DateTime,
status UInt16
)
ENGINE = MergeTree
ORDER BY (org_id, endpoint, ts);
Four queries against that table, and four different outcomes:
-- Q1
SELECT count() FROM requests WHERE org_id = 501;
-- Q2
SELECT count() FROM requests WHERE org_id = 501 AND endpoint = '/checkout';
-- Q3
SELECT count() FROM requests WHERE org_id = 501 AND ts > '2024-06-01';
-- Q4
SELECT count() FROM requests WHERE endpoint = '/checkout';
Q1 org_id = 501 → narrows on org_id (1-column prefix)
Q2 org_id = 501 AND endpoint = '/checkout' → narrows on (org_id, endpoint) (2-column prefix)
Q3 org_id = 501 AND ts > '2024-06-01' → reliably narrows on org_id only —
ts is skipped over endpoint, so it
can't be counted on to narrow further
Q4 endpoint = '/checkout' → doesn't narrow at all — endpoint
isn't the leading column
Q3 is the one that catches people off guard. It feels like it should narrow further than Q1, since it's a strictly more specific filter with a real condition on a real ORDER BY column. But ts is only sorted within each (org_id, endpoint) group, and this query leaves endpoint completely unconstrained — so within the matched org_id, rows are scattered across every endpoint value in whatever ts order each one happens to have. ts sits behind a gap in the prefix, so — reliably — it doesn't buy you anything beyond what org_id already narrowed.
Choosing column order is choosing which queries win
Because only a prefix counts, the order you list columns in ORDER BY is a real design decision, not a style preference. Put the column your queries filter on most often — and most selectively — first. Everything after it only helps queries that also pin down every column ahead of it.
This is also why "just list every column I ever filter on, in ORDER BY" isn't a strategy. A five-column ORDER BY doesn't give you five independent filter fast-paths — it gives you one fast-path per contiguous prefix, and most real queries will only ever supply the first one or two.
PRIMARY KEY: a prefix of ORDER BY, and sometimes a shorter one
Every example so far has left PRIMARY KEY out of the CREATE TABLE statement, because — as the previous module's lesson mentioned — when you don't specify one, ClickHouse uses the full ORDER BY expression as the primary key. That's the "mostly" in this lesson's title: the primary key must be a prefix of the sorting key, but it doesn't have to be the whole sorting key. You can declare one explicitly, shorter than ORDER BY:
CREATE TABLE requests
(
org_id UInt64,
endpoint String,
ts DateTime,
status UInt16
)
ENGINE = MergeTree
ORDER BY (org_id, endpoint, ts)
PRIMARY KEY (org_id, endpoint);
Rows are still physically sorted by the full three-column ORDER BY — that part doesn't change, and it's what a merge produces regardless of PRIMARY KEY. What changes is what the sparse index stores: with PRIMARY KEY (org_id, endpoint), the index only ever holds (org_id, endpoint) values per granule, not ts. Two consequences follow directly:
- The index itself is smaller and cheaper to hold in memory, since it's storing fewer columns' worth of values per entry across potentially millions of granules.
tscan never narrow a granule scan on its own, even for queries like Q3 that constrainorg_idandtstogether — the index has notsentries to check, at any prefix depth. Notice this doesn't actually lose you anything Q3 already had:tswas already not reliable for narrowing past theendpointgap. What you're giving up by shortening the primary key is exactly the narrowingtswould have added on top of a full(org_id, endpoint)match — i.e., a query likeorg_id = 501 AND endpoint = '/checkout' AND ts > '2024-06-01'would have narrowed to a tighter range of granules with the full three-column primary key than it does with the two-column one.
So why give up any narrowing at all? Because ts doesn't stop mattering just because it's outside the primary key — it's still the column deciding physical row order within each (org_id, endpoint) group, which keeps similar ts values adjacent on disk. That adjacency is what compression algorithms exploit: physically neighboring values that are close to each other compress better than the same values scattered at random. A shorter primary key is a trade: less memory spent on an index, in exchange for one narrowing step you'd otherwise get on queries that happen to constrain every column ahead of the dropped one too.
Common Misconception
"ORDER BY and PRIMARY KEY are just two names ClickHouse uses for the same setting — whichever one I write, the effect is identical."
They coincide by default, which is exactly what makes this an easy assumption to carry. But they answer two different questions: ORDER BY decides the physical sort order of every row in a part — full stop, no exceptions. PRIMARY KEY decides which prefix of that sort order the sparse index actually stores entries for, and it's allowed to be a strictly shorter prefix than the sort order itself. When they're equal (the default, and the common case), the distinction is invisible. It stops being invisible the moment someone reaches for a table with a long, compression-friendly ORDER BY and a deliberately shorter PRIMARY KEY to keep the index small — at which point treating the two as interchangeable will make you overestimate what the index can narrow.
Predict, Then Verify
A table is defined as:
ORDER BY (customer_id, region, event_time)
PRIMARY KEY (customer_id)
Three queries run against it:
WHERE customer_id = 88WHERE customer_id = 88 AND region = 'eu-west'WHERE region = 'eu-west'
For each, predict whether the primary index narrows the granule scan, and if so, by how much of the ORDER BY/PRIMARY KEY prefix.
Reveal the answer →
Query 1 narrows fully on customer_id — the entire primary key. customer_id is both the leading ORDER BY column and the only column in this table's PRIMARY KEY, so the index has exactly the entries needed to rule out every granule that doesn't contain matching rows.
Query 2 narrows no further than Query 1 did. region is the second column of ORDER BY and would normally extend the prefix — but PRIMARY KEY here is (customer_id) alone, so the sparse index has no region entries at any depth. The rows are still physically grouped by region within each customer_id, but that grouping isn't reflected in the index, so this query gets exactly the same granule-level narrowing as Query 1, then filters region by scanning within the matched granules.
Query 3 narrows nothing. region isn't the leading column of either ORDER BY or PRIMARY KEY, so this is the same case as filtering on any unrelated column — every granule gets read, and the column-level (not row-level) scan from the previous module's lesson is what keeps it from being catastrophic.
Summary
The sparse primary index can only use a left-anchored, contiguous prefix of ORDER BY's columns — every column in that prefix needs an equality condition except possibly the last one, which can be a range. A filter on a column that's second or third in ORDER BY narrows nothing if the column(s) ahead of it aren't also constrained, no matter how selective that filter looks on its own. That makes column order in ORDER BY a real design decision: put what your queries filter on most, and most selectively, first. ORDER BY and PRIMARY KEY coincide by default — when no PRIMARY KEY is given, the whole sorting key doubles as the primary key — but PRIMARY KEY can be declared as a strictly shorter prefix. Rows stay fully sorted by all of ORDER BY either way, so the extra trailing columns still cluster similar values together for compression; they just stop being available to the index for granule-skipping once they fall outside the (possibly shorter) primary key.
Quiz
1. A table is ORDER BY (a, b, c). A query filters WHERE b = 5 AND c = 10, with no condition on a. Does the primary index narrow the granule scan?
2. ORDER BY (a, b, c). A query filters WHERE a = 1 AND c = 3, leaving b unconstrained. How far can you reliably count on the primary index to narrow?
3. By default, with no PRIMARY KEY clause in a CREATE TABLE statement, what is a MergeTree table's primary key?
4. Why would a team deliberately declare PRIMARY KEY (a, b) on a table whose ORDER BY is (a, b, c), rather than leaving PRIMARY KEY unset?
5. A table is ORDER BY (customer_id, region, event_time) with PRIMARY KEY (customer_id) explicitly declared shorter than the sorting key. A query filters WHERE customer_id = 9 AND region = 'apac'. Compared to a version of the same table with no separate PRIMARY KEY (so the primary key equals the full ORDER BY), how does this query's granule narrowing compare?