Granules and the sparse primary index

Rows are grouped into 8192-row blocks called granules, and the primary index keeps one entry per granule — small enough to fit the whole table's index in RAM — so binary search picks the granule range in microseconds.

Previously

If rows inside a part are sorted, the engine doesn't need an index entry per row — it can keep one entry per group of 8192 rows and still find a range fast. That gives us an index small enough to keep entirely in memory.

Scene 09

Granules and the sparse primary index

  1. Watch
  2. Try it
  3. Predict
  4. Capture
QUERYWHERE service='api' AND ts > '2026-04-01'INDEX FOOTPRINT1.2 MB / 1B rowsprimary.idx — one entry per granuleONE PART · 120 granules · 8,192 rows each.mrk2 — granule N → (compressed block offset, uncompressed offset)rows examined: ~49,152 of 1B6 of 120 granules readPart: 1,083 granules · index_granularity = 8,192 rows per granule.
What to watch for

A WHERE service='api' AND ts > T query enters. The primary.idx (in RAM) is binary-searched in O(log G) ≈ 7 hops; granules 42..47 survive and the rest stay dim. Watch the four stages.

Continue unlocks when the animation finishes.
Implementation

Highlighted lines are the ones running in the diagram right now.

Part.build_primary_index()
one entry per granule of N rows — that's the whole trick
def build_primary_index(part):
g = cfg.index_granularity # rows per granule
primary_idx = []
for start in range(0, part.rows, g):
granule = part.rows_sorted[start : start + g]
primary_idx.append(granule[0].sort_key)
write_file('primary.idx', primary_idx)
# finer g → more entries, fatter index in RAM
# coarser g → fewer entries, but fatter granule reads
Planner.plan_with_index(query)
binary-search primary.idx for overlapping granules; load only those
def plan_with_index(query):
lo, hi = bisect_range(
primary_idx, query.predicate_range,
)
surviving = list(range(lo, hi + 1))
for gid in surviving:
block = mark_file.locate(gid)
rows = decompress_and_read(block)
emit(vectorized_filter(rows, query))
# other granules: not a byte of .bin is read
index_size_math
why a billion-row primary.idx is ~1 MB and lives in RAM
def primary_idx_bytes(part):
g = cfg.index_granularity
entries = part.rows // g # N_rows / granule_size
return entries * sizeof(sort_key_tuple)
def rows_pulled_per_match(part):
# one surviving granule still loads g rows;
# vectorized filter discards the non-matches.
return cfg.index_granularity

Where this sits in Build a columnar OLAP store (ClickHouse / Druid style)

Scene 09 of 13, in the Read side act — Sort key, sparse primary index, skip indexes — narrowing what to scan.. Rows are grouped into 8192-row granules; one index entry per granule keeps the whole table's index in RAM, binary-searched in microseconds.

Up next. The sparse index narrows a query to a granule range using the sort-key prefix. For predicates on OTHER columns — not in the sort key — we need a second kind of index that can prove 'this granule definitely doesn't contain X'.

All 13 scenes in Build a columnar OLAP store (ClickHouse / Druid style) · Every curriculum

Built with Arqly
Every scene in Build a columnar OLAP store (ClickHouse / Druid style) builds on the one before it.All 13 Build a columnar OLAP store (ClickHouse / Druid style) scenes