Why can a columnar engine group by a dictionary-encoded string column faster by hashing the integer codes?
answer
- the key is already an integer in the vector
- no byte comparison, no pointer chase
- small code range needs no hash at all
- strings are looked up once per group, not per row
- codes are local to a file, not global
basics
~20 sGrouping on small fixed-width dictionary codes replaces string hashing and byte-by-byte comparison with integer operations on values already in cache. When the code range is small the engine can even index an array directly instead of hashing, and decode group labels once at the end.
solid answer
~50 sA dictionary-encoded column stores each distinct string once in a dictionary and keeps a narrow integer code per row. `GROUP BY country` normally hashes a variable-length string per row and, on every hash collision, compares bytes through a pointer. If the engine aggregates on the codes instead, the key is a fixed-width integer already sitting in the column vector: hashing is a couple of instructions, equality is a single compare, the keys pack densely into cache, and the whole loop vectorizes. When the dictionary is small, it goes further and uses the code as a direct array offset — no hash function and no collisions at all. The strings are looked up once per group at the end, so a billion-row aggregation touches the dictionary only as many times as there are output groups. The catch is that dictionaries are usually per-file or per-chunk, so codes are not comparable across files and must be remapped or the partial results merged on the decoded values.
code
sql · 9 lines-- both group the same rows; the left key is a narrow code in the vector,
-- the right key is a variable-length string behind a pointer
SELECT country_code, count(*), sum(amount_cents)
FROM events
GROUP BY country_code;
SELECT free_text_note, count(*)
FROM events
GROUP BY free_text_note;go deeper
Know that repeated strings are stored once with a small number standing in for them, and that comparing numbers is cheaper than comparing text.
Explain the execution win concretely: fixed-width hash keys, no byte comparison, a hash table that fits in cache, and decoding group labels once at the end.
Bring the caveat unprompted. Dictionaries are per file or per chunk, so codes need remapping or partial merges, and high-cardinality columns lose the encoding entirely — diagnose a slow group-by with that in mind.
Connect it to modelling and cost. Choosing narrow, low-cardinality grouping keys in the physical model is what makes dashboards cheap at scale, and it is a design decision made at load time, not query time.
## Setup: what dictionary encoding gives execution Dictionary encoding stores a column as two things: a dictionary of the distinct values, and, for each row, a small integer code identifying which dictionary entry it holds. A `country` column with 200 distinct values becomes a vector of one- or two-byte codes plus a 200-entry string dictionary. It is chosen for compression, but its effect on *execution* is at least as important, and this is where the interview question lives. ## The cost of grouping on raw strings A hash aggregation over `GROUP BY country` does, per row: load a pointer and a length, run a hash function over the bytes, probe a hash table, and on any collision or match compare the bytes to confirm equality. Three of those steps are variable-cost and pointer-chasing; the string bytes live somewhere other than the column buffer, so each probe risks a cache miss the prefetcher cannot anticipate. None of it vectorizes well, because lane width is undefined for variable-length data. ## The cost of grouping on codes Now do the same on the codes. Per row: load a fixed-width integer from a contiguous buffer, mix it into a hash with a couple of arithmetic instructions, probe, compare with one integer compare. Everything is fixed width, everything is in the vector already being streamed, equality is exact with no byte comparison, and the loop is branch-free enough to keep the pipeline full. The hash table's keys are also 4 or 8 bytes rather than pointers to strings, so far more of it fits in cache — which matters enormously, because a hash aggregation's probe is the classic random-access cache-miss generator. ## The special case worth naming: no hash at all If the engine knows the code range is small — say fewer than a few thousand distinct values — it can skip hashing entirely and use the code as an **array index** into a dense array of accumulators. That is a perfect, collision-free hash: one load, one add, done. Multi-column grouping can use the same trick by packing several narrow codes into one integer key ("grouping keys as a bit-concatenation"), and only falling back to a real hash table if the packed key space is too large. This is why low-cardinality `GROUP BY` in a columnar engine can run at close to scan speed. ## Decoding late The accumulators are keyed by code, so the output labels are missing. The engine resolves them once at the end: for each *group*, look up the dictionary entry. Over a billion input rows producing 200 groups, the dictionary is consulted 200 times rather than a billion. The same idea applies to filters: `WHERE country = 'DE'` can be answered by resolving `'DE'` to its code once and then comparing integers for the whole chunk — and if the value is absent from a chunk's dictionary, the entire chunk is skipped without reading its codes. ## The complication: dictionaries are local The reason this is a real interview question rather than a trivia fact is the failure mode. Dictionaries in analytical formats are typically built **per file, per row group or per chunk**, not globally, because a global dictionary would need coordination across every writer. Consequently code `7` in one file and code `7` in another may be different strings. An engine therefore cannot blindly aggregate codes across files. The usual strategies: - **Per-chunk aggregation, then merge on decoded values.** Aggregate on codes within a chunk, decode the (few) group keys, and merge partials globally on the real values. - **Remapping.** Build a translation table from each chunk's local codes into a query-scoped global code space as chunks are opened, then aggregate globally on the remapped codes. - **Give up per chunk.** If a chunk's dictionary is huge or the column falls back to plain encoding — which writers do when cardinality grows too high — the engine aggregates on values for that chunk. That last point is the practical caveat: dictionary encoding is chosen by the writer based on observed cardinality, so a column you *believe* is dictionary-encoded may not be in every file. High-cardinality columns (UUIDs, free text, timestamps at microsecond precision) usually are not, and the code-level speedup silently disappears. If a `GROUP BY` on what you thought was a low-cardinality column is slow, checking whether the column is actually dictionary-encoded, and whether the encoding survived the last load, is a legitimate first move. ## What to demonstrate Name the three wins in order — fixed-width keys, cache-resident hash table, direct-indexed accumulators for small ranges — then the decode-once-per-group point, then the per-chunk-dictionary caveat. The last one is what separates a memorized answer from an operational one.
- Why can't an engine simply aggregate on dictionary codes across all the files it scans?Because dictionaries are usually built per file or per row group, so the same code means different values in different files. The engine either aggregates per chunk and merges partial results on decoded values, or remaps each chunk's local codes into a query-scoped global code space first. Assuming codes are globally comparable is a correctness bug, not a performance one.
- When does this optimisation quietly stop applying?When the writer decided the column was too high-cardinality to dictionary-encode and fell back to plain values, or when the dictionary is so large it no longer fits comfortably in cache. UUIDs, free text and microsecond timestamps typically fall out this way, so a `GROUP BY` on them behaves like ordinary string grouping.
saying these in an interview costs you the question
- Assumes dictionary codes are globally consistent across files
- Thinks the dictionary is consulted once per row rather than per group
- Believes every string column is dictionary-encoded
- Says the speedup comes only from smaller storage, not from execution
- Confuses this with a storage-level index on the column