Joins — denormalize or pay
Column-store joins are bound by RAM (hash join) or network shuffle (sharded), so the canonical OLAP answer is to denormalize dimensions into the fact row at write time — storage is cheap, query latency is the constraint.
Materialized views collapse one big scan into a tiny one. But the reader's other OLTP instinct — JOIN across normalized tables — hits a different kind of wall on a column store, and the answer feels wrong.
Scene 12
Joins: denormalize or pay
- Watch
- Try it
- Predict
- Capture
The star-schema panel runs the OLTP-instinct query: hash table builds from the 1M-row users dim (200 MB in RAM), then events probes it row by row. Wall-clock pins near 4 s — the hash join is RAM-bound by the right side, not by how cleverly you wrote the SQL.
Highlighted lines are the ones running in the diagram right now.
def hash_join_star_schema():# algorithm = 'hash' (ClickHouse default)ht = {} # hash table in RAMfor u in scan(users): # build on RIGHTht[u.id] = u.tier # ~1M rows -> ~200 MBout = Counter()for e in scan(events): # probe per LEFT rowtier = ht.get(e.user_id) # one lookup per eventout[(e.country, tier)] += 1return out # ~4 s; RAM-bound on |users|
def denormalized_query():# tier was baked into the row at write timeout = Counter()for e in scan(events_wide, # only the columns we needcols=['country', 'tier']):out[(e.country, e.tier)] += 1return out # ~0.2 s; one wide scan# update cost: a user upgrading tier => rewrite every# event row that user ever produced (parts are immutable).
def dictionary_lookup_query():# users_dict pre-loaded once into RAM (~30 MB)# algorithm = 'direct' (no hash-build at query time)out = Counter()for e in scan(events, cols=['country', 'user_id']):tier = dictGet('users_dict', 'tier', e.user_id)out[(e.country, tier)] += 1return out # ~0.4 s# refresh: LIFETIME(MIN 300 MAX 600) reloads from source# every ~5-10 min — no event-row rewrites on tier change.
Where this sits in Build a columnar OLAP store (ClickHouse / Druid style)
Scene 12 of 13, in the Patterns act — Materialized views and the OLAP answer to JOIN — denormalize or dictGet.. Column-store joins are RAM-bound or shuffle-bound; the canonical OLAP answer is to denormalize at write time — storage is cheap, latency is the constraint.
Up next. We've now seen every lever — layout, encoding, execution, parts, merge, sort key, sparse index, skip index, MVs, denormalization. The last move is to face a real workload and pick the configuration end-to-end.
All 13 scenes in Build a columnar OLAP store (ClickHouse / Druid style) · Every curriculum