How the machinery works when nobody is looking. What a hash aggregation does when it runs out of memory, what a Bloom filter can and cannot tell you, and why a columnar engine reads a predicate before it reads a column.
What a GROUP BY actually does to memory, what happens when it runs out, and why the engine sometimes sorts instead.
Joining
3
Three algorithms, chosen from statistics. Which one an engine picks, and what each one costs when the estimate was wrong.
Structures that trade certainty for space
4
A Bloom filter answers one question, in one direction, and is wrong in a known way. That shape recurs in every sketch.
Columnar storage and scans
6
Why a column store reads a predicate before it reads a column, and why the encoding that halves one file doubles another.
01 / 20
Hash vs sort aggregationExternal merge sort & spillingCardinality estimation
An engine can compute `GROUP BY customer_id` with a hash table or by sorting first. What decides which, and what is in memory in each case?
Why they ask this
It is the mechanism behind every out-of-memory aggregation, and the answer tells the interviewer whether the candidate knows that the hash table is sized by *groups* rather than by rows.
Say this
Hash aggregation makes one pass and holds one entry per distinct group. Sort aggregation sorts first and then sweeps, holding one group at a time. Hash wins when the groups fit; sort wins when they do not, or when the output has to be ordered anyway.
The reasoning
Hash aggregation reads each row once, hashes the key, and updates an accumulator. Its memory is the number of *distinct keys*, not the number of rows — five hundred thousand rows over fifty groups needs a table of fifty. That is why it is the default: one pass, and usually a small table.
Sort aggregation orders the rows by the grouping key and then sweeps through, emitting a group whenever the key changes. Running state is a single group, so its memory is tiny — but the sort is `n log n` over everything, and the sort is the thing that spills.
The engine chooses from an estimated distinct count. Low cardinality means hash; very high cardinality, or an input that is already sorted on the key, or an `ORDER BY` on the same key that would need a sort anyway, tips it toward sort. When the estimate is wrong and the hash table does not fit, the engine spills partitions to disk and finishes them separately — which is the same partition-then-conquer idea, applied under duress.
Two consequences worth stating. First, a `GROUP BY` on a near-unique key is the worst case for hashing, because the table approaches the size of the input — grouping by an id column is how an aggregation OOMs. Second, hash aggregation returns groups in no meaningful order, and code that relies on the order it happens to produce breaks when the engine spills and changes it.
See it run on CPython 3.12
Both aggregations run and are asserted to agree; only the cost differs.
"""Hash aggregation vs sort aggregation, counted rather than asserted."""
import random
rng = random.Random(3)
def hash_aggregate(rows):
"""One pass, a hash table sized by the number of groups."""
table, probes = {}, 0
for key, value in rows:
probes += 1
table[key] = table.get(key, 0) + value
return table, {"passes": 1, "probes": probes, "peak_entries": len(table)}
def sort_aggregate(rows):
"""Sort, then a single sequential sweep. Memory is the sort, not the groups."""
ordered = sorted(rows) # the expensive part: n log n, and it may spill
out = {}
current, total = None, 0
for key, value in ordered:
if key != current:
if current is not None:
out[current] = total
current, total = key, 0
total += value
if current is not None:
out[current] = total
return out, {"passes": 2, "peak_entries": 1}
for label, groups in [("few groups", 50), ("many groups", 200_000)]:
rows = [(f"k{rng.randrange(groups)}", 1) for _ in range(500_000)]
hashed, hstats = hash_aggregate(rows)
sorted_, sstats = sort_aggregate(rows)
assert hashed == sorted_, "the two must agree — only the cost differs"
print(f"{label}: {len(rows):,} rows, {len(hashed):,} distinct keys")
print(f" hash: {hstats['passes']} pass, hash table holds {hstats['peak_entries']:,} entries")
print(f" sort: {sstats['passes']} passes, running state holds {sstats['peak_entries']} group")
print()
print("the hash table is bounded by the number of groups, not the number of rows —")
print("which is why hash aggregation is the default, and why it is the one that spills.")
Prints
few groups: 500,000 rows, 50 distinct keys
hash: 1 pass, hash table holds 50 entries
sort: 2 passes, running state holds 1 group
many groups: 500,000 rows, 183,206 distinct keys
hash: 1 pass, hash table holds 183,206 entries
sort: 2 passes, running state holds 1 group
the hash table is bounded by the number of groups, not the number of rows —
which is why hash aggregation is the default, and why it is the one that spills.
The answer most people give
"Hash aggregation uses more memory because it holds all the rows." It holds one entry per group. The distinction matters because it means cardinality, not volume, predicts whether it fits.
They’ll ask next
You are grouping a billion rows by a column that is nearly unique. What does the engine do, and what would you change?
External merge sort & spillingHash vs sort aggregation
You need to sort two terabytes with sixteen gigabytes of memory. Describe the algorithm, and say what doubling the memory buys you.
Why they ask this
External merge sort is the foundation under spilling, shuffles and merge joins. The second half is the discriminator: people expect memory to trade linearly against time, and it does not.
Say this
Sort what fits into runs, write them out, then merge runs together a fan-in at a time. Doubling memory does not halve the work — it only helps when it removes a whole merge pass.
The reasoning
Pass one: read as much as fits, sort it in memory, write it out as a sorted run. Repeat. Two terabytes with sixteen gigabytes gives about a hundred and twenty runs. Pass two onward: open `fan_in` runs at once, read their heads into a priority queue, and repeatedly emit the smallest — merging them into longer runs until one remains.
The cost is passes, and passes are `1 + ceil(log_fanin(runs))`. The step worth being explicit about is what a pass costs: it reads every byte and writes every byte, so one pass over two terabytes moves four — two in, two out. Three passes is therefore twelve terabytes of I/O to sort two. That is why the pass count, and not the row count or the CPU, is the number you are optimising.
Which is why memory behaves as a step function. Sixteen gigabytes and four gigabytes both give three passes; sixty-four gigabytes gives two. Between the steps, extra memory buys nothing at all — it makes each run bigger without changing how many merge rounds those runs need. The lever that usually matters more is fan-in, because raising it lowers the log's base.
The practical notes: fan-in is bounded by how much buffer each open run needs, so a very high fan-in means tiny reads and the disk stops being sequential — there is an optimum, not a maximum. And this is exactly what a database means by 'spilling': the hash table that did not fit becomes runs on disk, and the recovery is a merge.
See it run on CPython 3.12
Arithmetic, not a benchmark: passes are a function of memory and fan-in.
The answer most people give
"Twice the memory, half the time." The relationship is logarithmic and stepped. Going from sixteen to thirty-two gigabytes here changes nothing; going to sixty-four removes a pass and a third of the I/O.
They’ll ask next
Would you rather double the memory or double the fan-in, and why?
A job's plan says a hash aggregation spilled. What has the engine done, and why is the second run of the same query sometimes fine?
Why they ask this
Spilling is reported everywhere and understood rarely. The 'sometimes fine' half tests whether the candidate knows spilling is a data-dependent decision rather than a fixed property of the query.
Say this
The hash table exceeded its memory budget, so the engine partitioned it and wrote partitions to disk, then processed them one at a time. It varies between runs because the trigger is the data volume and the memory actually available, both of which change.
The reasoning
When the table will not fit, the engine partitions the input by a hash of the key — the same key it was grouping on — so that all rows for a given key land in one partition. Each partition is written to disk. Then each is read back and aggregated on its own, because a partition is small enough to fit. The result is identical; the cost is a full write and read of the data, plus the risk of a partition that is itself too big.
That last risk is where spilling turns into a real problem: if one key is enormous, its partition does not shrink no matter how many partitions you make, and the engine recurses or fails. Skew and spilling are the same problem seen from two angles.
It varies run to run because three things vary: how much data actually arrived, how much memory the executor got given other concurrent work, and how good the optimizer's cardinality estimate was. A query at the edge of its budget spills on a busy day and not on a quiet one, which is why 'it worked yesterday' is not evidence.
What to do about it, in order: reduce what is grouped — filter and project before aggregating, so fewer and narrower rows reach the table; check the estimate, since a bad cardinality guess means the engine sized for the wrong thing; give it more memory if the shortfall is small; and if one key dominates, treat it as skew rather than as a memory problem, because more memory will not fix a single enormous group.
The answer most people give
"Spilling means the query failed and retried." It completes correctly — spilling is the recovery. What you lose is time and I/O, which is why it shows up as a slowdown rather than an error.
They’ll ask next
One key accounts for a third of the rows. Does more memory help?
Name the join algorithms an engine can choose between, and say what each costs and when it is picked.
Why they ask this
Bread and butter for any engine question, and the interviewer is listening for cost expressed in terms of what is read and held rather than as a list of names.
Say this
Nested loop scans the inner side per outer row and is only sane when one side is tiny. Hash join builds a table on the smaller side and probes it — one pass each, memory proportional to the build side. Sort-merge sorts both and sweeps — no big hash table, but two sorts.
The reasoning
**Nested loop** is `O(n·m)` and is chosen when one side is very small or when the join condition is not an equality — a range or inequality join has nothing to hash on, so this is often the only option. With an index on the inner side it becomes an index nested loop, which is the right plan for a small number of outer rows.
**Hash join** builds an in-memory table from the smaller (build) side, then streams the larger (probe) side through it. One pass over each, and the memory is the build side. It is the default for equi-joins and the reason engines care so much about which side is smaller — getting that backwards is a common cause of a job that spills.
**Sort-merge** sorts both inputs on the join key and walks them together. It costs two sorts but needs almost no memory during the merge, it handles inputs that are already sorted for free, and it degrades gracefully to disk. It is the fallback when the build side will not fit and the choice when both sides are enormous.
The distributed layer adds a second decision on top: broadcast versus shuffle. A broadcast hash join ships the small side to every node and avoids moving the large side at all; a shuffle hash or sort-merge join repartitions both sides by the key. Which one is chosen comes from an estimate of the small side's size, and the classic failure is a broadcast of something the optimizer thought was small — which is an out-of-memory error on the driver rather than a slow query.
The answer most people give
"Hash join is always fastest." It is usually fastest and it needs the build side to fit. When it does not, sort-merge is both faster and the only one that finishes.
They’ll ask next
The join key is `a.ts BETWEEN b.start AND b.end`. Which algorithms are still available?
An engine builds a Bloom filter from one side of a join and pushes it to the scan of the other. What does that buy, and what can it not do?
Why they ask this
Runtime filters are in every modern engine and are a good test of whether the candidate connects a data structure to a query plan rather than treating them as separate topics.
Say this
It lets the probe-side scan discard rows that certainly have no match before they are read or shuffled. It cannot remove every non-matching row, because a Bloom filter's false positives pass through — so it is a pre-filter, not the join.
The reasoning
The engine builds the hash table for the build side and, while doing so, inserts each join key into a small Bloom filter. That filter is then sent to the probe side's scan. Rows whose key the filter rejects cannot possibly join, so they are dropped at the scan — before decompression, before the shuffle, before the hash probe.
The saving is proportional to how selective the join is. Joining a billion-row fact table to a thousand-customer dimension means almost every fact row has no match, and a runtime filter can eliminate most of them at the storage layer. That is a much bigger win than making the join itself faster, because the cheapest row is the one never read.
What it cannot do is be exact. A Bloom filter has false positives, so some non-matching rows survive the filter and are correctly discarded later by the actual join. It has no false negatives, which is the property that makes this safe: a row it rejects is guaranteed not to match, so no result is ever lost. Using a structure with false negatives here would silently drop rows.
The cost side, since interviewers push on it: the filter has to be built before the probe side can be scanned, which serialises two things that might otherwise overlap, and it is wasted work when the join is not selective. Engines therefore apply it adaptively — build the filter, check how selective it turned out to be, and skip it if it is rejecting almost nothing.
The answer most people give
"It replaces the join." It only pre-filters. Every surviving row still goes through the real join, because the filter's false positives mean 'maybe', never 'yes'.
They’ll ask next
Why must the structure used here have no false negatives, and what would happen if it did?
You need a membership test for a hundred million keys and cannot hold them in memory. How big does a Bloom filter need to be for a 1% false-positive rate, and what does it tell you?
Why they ask this
It tests whether the candidate can reason about a sketch quantitatively rather than describing it. The answer is a formula and a number, and most people only have the description.
Say this
About ten bits per key and seven hash functions gives roughly 1% — so a hundred million keys is around 120 MB. It answers 'definitely not present' or 'possibly present', never 'definitely present'.
The reasoning
The structure: a bit array of `m` bits and `k` hash functions. To add a key, set the `k` bits it hashes to. To test, check them all — if any is zero the key was definitely never added, and if all are one it was *probably* added. The asymmetry is the whole point: no false negatives, some false positives.
The sizing: the false-positive rate is `(1 - e^(-kn/m))^k`, and the optimal `k` for a given `m/n` is `(m/n)·ln2`. Ten bits per key with seven hashes lands near 1%; the table beside this shows measured rates tracking the formula closely at four, eight, twelve and sixteen bits per key. Knowing the shape matters more than the exact constant: the rate falls exponentially in bits per key, so 1% costs about ten bits and 0.1% about fifteen — cheap to improve.
What it buys is that the expensive lookup only happens for keys the filter admits. Checking a hundred million keys against a remote store becomes a bit-array probe that rejects most of them locally, and only the maybes go over the network. The false-positive rate is therefore a *cost* parameter, not a correctness one — a 1% rate means 1% wasted lookups, and the answer is still exact because the real store confirms.
The limits to state before being asked: you cannot delete from a standard Bloom filter (clearing bits would create false negatives) — a counting Bloom filter or a cuckoo filter can. You cannot enumerate what is in it. And the rate degrades as you exceed the `n` you sized for, so a filter built for a hundred million keys and fed two hundred million is far worse than its stated rate, silently.
See it run on CPython 3.12
100,000 members and 100,000 non-members, blake2b, CPython 3.12.
The answer most people give
"It might say a key is missing when it is present." That is the one error it cannot make. Reversing the direction inverts every reason to use it, because the safety of a runtime filter depends on no false negatives.
They’ll ask next
You need to remove keys as they expire. What changes?
The same table stored row-wise and column-wise. What makes the column store faster for `SELECT sum(amount) FROM orders`, and when is it slower?
Why they ask this
A foundational storage question, and the 'when is it slower' half stops it being a recital of marketing points.
Say this
It reads only the `amount` column instead of every byte of every row, and that column compresses far better because it is one type with repeated values. It is slower when you need whole rows, especially single-row lookups and writes.
The reasoning
Row layout stores each record's fields together, so reading one column means reading — and decompressing — every other column with it. Column layout stores each column contiguously, so a query touching two of forty columns reads a twentieth of the bytes. That is projection pushdown, and on wide tables it is usually the largest single win.
Compression is the second effect and often the bigger one. A column is values of one type with real redundancy, so dictionary, run-length and delta encodings work extremely well — where a row-wise page interleaves types and defeats them. Better compression is fewer bytes read, which compounds with reading fewer columns.
The third is execution shape. A contiguous run of same-typed values is what a vectorised engine wants: process a batch of ten thousand values in a tight loop, no per-row interpretation, SIMD where available. Row-at-a-time execution spends most of its time on overhead rather than on arithmetic.
Where it loses: fetching a whole row means touching every column's file and stitching the record back together, so point lookups by primary key are much worse than a row store's. Writes are worse — inserting one row touches every column — which is why columnar formats prefer large batch appends and why OLTP stays row-oriented. And a query selecting `*` on a narrow table gives up the projection advantage entirely.
The answer most people give
"Columnar is faster because it is compressed." Compression is a consequence of the layout, not the mechanism. The primary win is reading only the columns the query names, and compression multiplies it.
They’ll ask next
Your query is `SELECT * FROM orders WHERE order_id = 12345`. Which layout do you want?
Dictionary, run-length and delta encoding on three different columns. Which wins on each, and when does an encoding make a file bigger?
Why they ask this
It moves the candidate from 'compression is good' to reasoning about data shape, and the backfiring case is what shows they have actually looked at file sizes.
Say this
Dictionary wins on low-cardinality strings, run-length on sorted or clustered columns, delta on ascending numbers. Each backfires on the wrong shape — dictionary on a high-cardinality column stores a dictionary bigger than the data.
The reasoning
**Dictionary** replaces each value with an index into a list of distinct values. It is dramatic on low-cardinality strings — a country column becomes a few bits per row — and it is worse than raw when nearly every value is distinct, because you store the values *and* the indexes. The measured table shows exactly that: dictionary on an ascending id column is larger than the raw encoding.
**Run-length** stores value-and-count pairs. It collapses a sorted column almost to nothing, and on an interleaved column with no runs it is worse than raw — every 'run' is length one, so you have doubled the data. The same column, sorted or not, differs by orders of magnitude.
**Delta** stores each value's difference from the previous one, which is small and fixed-width for a monotonically increasing column like a timestamp or an id. On unordered numbers the deltas are as large as the values and it buys nothing.
Which leads to the point that matters operationally: **sort order is an encoding decision.** Sorting a table by a low-cardinality column before writing turns run-length from useless into free, and improves everything downstream of it. And general-purpose compression on top (gzip, zstd) is not an alternative to encodings — it works with them, and it costs CPU on every read, which is the trade a query engine is making when it chooses a lighter codec than the smallest one available.
See it run on CPython 3.12
One million values per column, encoded four ways, CPython 3.12.
The answer most people give
"Turn on the strongest compression available." The measurements show two encodings producing files larger than the raw data on the wrong column, and the strongest codec costs decompression CPU on every single read.
They’ll ask next
You can sort the table by one column before writing. Which one, and what does it change?
A query filters on `region = 'EU'`. Trace where that predicate can be applied, from the query engine down to the bytes on disk.
Why they ask this
It tests whether the candidate can see the layers. Each level of pushdown removes an order of magnitude more work than the one above it, and knowing the order is what makes a slow query diagnosable.
Say this
Four levels: partition pruning skips whole directories, file and row-group statistics skip files and blocks, the scan applies the predicate while decoding, and only then does the engine filter rows in memory. The cheapest is the highest.
The reasoning
**Partition pruning** is first and cheapest. If the data is laid out as `region=EU/...`, the planner never lists the other directories. No file is opened. This is why partition columns should be the ones you filter on most, and why partitioning by a high-cardinality column is a mistake — it creates millions of tiny files and the listing itself becomes the bottleneck.
**File and row-group statistics** come next. Columnar formats store min/max per column per row group, so a group whose `region` range excludes 'EU' is skipped without decompressing it. This only works if the data is clustered — if every row group contains every region, every min/max spans everything and nothing is skipped. Sorting on the filter column is what makes zone maps effective, which is the same lesson as the encoding one.
**Scan-level evaluation** applies the predicate during decoding, often against dictionary codes rather than decoded values — so a dictionary-encoded column can be filtered by comparing integers and never materialising the strings. Bloom filters attached to a row group serve the same purpose for equality on high-cardinality columns, and a runtime filter from a join arrives at this level too.
**Engine-level filtering** is the fallback: rows are fully read and then discarded. It is correct and it is the level where you have already paid for everything. So when a query is slow, the diagnostic question is which of the higher levels failed to engage — usually because the predicate is on a column the layout does not support, or is wrapped in a function the engine cannot push through, such as `WHERE upper(region) = 'EU'`.
The answer most people give
"The engine reads the data and applies the WHERE clause." That is the last resort. Three cheaper levels exist above it, and a query that is only using the last one is usually reading a hundred times more than it needs.
They’ll ask next
Why does `WHERE upper(region) = 'EU'` often defeat all of this?
What does it mean for an engine to be vectorised, and why is it faster than processing a row at a time when the arithmetic is identical?
Why they ask this
It tests understanding of where time actually goes in a query engine — which is overhead, not arithmetic — and that is the insight behind every modern execution engine.
Say this
It processes batches of a few thousand values from one column at a time in a tight typed loop, instead of one row through a chain of operators. The arithmetic is the same; what disappears is the per-row interpretation overhead.
The reasoning
In classic row-at-a-time execution, each row is pulled through a tree of operators, and every operator does a virtual call and a type check per row. For a simple sum, the interpretation overhead dwarfs the addition — the CPU spends its time deciding what to do rather than doing it.
A vectorised engine passes batches — typically a thousand to ten thousand values of one column — between operators. The inner loop is then a simple typed loop over contiguous memory: no branching per value, good cache locality, and a shape the compiler can auto-vectorise into SIMD instructions that do several values per cycle.
The batch size is a real tuning parameter, and the reason it is not simply 'as big as possible' is cache. A batch should fit comfortably in L1 or L2 so that a chain of operators works on hot data; too large and each operator evicts the next one's working set, too small and the per-batch overhead returns.
The alternative approach worth naming is compilation — generating machine code for the whole query so there are no operator boundaries at all. Both attack the same problem, and both need columnar input to work well. That is the deeper point: columnar storage, compression, and vectorised execution are one design, not three features, and an engine that reads column batches from disk can hand them to the execution layer without transformation.
The answer most people give
"It uses SIMD, so it does four additions at once." SIMD is part of it and the larger win is eliminating per-row interpretation. An engine with no SIMD at all still gets most of the benefit from batching.
They’ll ask next
Why is the batch size a few thousand rather than a million?
Work partitioning & skewExternal merge sort & spillingHash vs sort aggregation
A distributed `GROUP BY` needs a shuffle. Describe what physically happens to the bytes.
Why they ask this
Shuffles dominate the cost of distributed jobs and most candidates describe them as 'moving data' without knowing that it hits local disk first.
Say this
Each task partitions its output by a hash of the key and writes one sorted file per destination to local disk. Every reducer then fetches its partition from every mapper over the network. It is a write, a fetch, and a merge — not a direct transfer.
The reasoning
The map side: each task computes `hash(key) % partitions` for every row, buffers rows per partition, sorts and spills them, and finally writes a single shuffle file plus an index of where each partition starts. That write is to *local disk*, which is the part people miss — a shuffle is not a network operation with a bit of buffering, it is a full materialisation.
The reduce side: reducer `i` must fetch partition `i` from every one of the map tasks. With `m` mappers and `r` reducers that is `m × r` fetches, which is why shuffles scale badly in the number of tasks and why very high partition counts hurt even when each partition is small.
Then the reducer merges what it fetched — typically a sorted merge, which is external merge sort again if the partition does not fit in memory. So one shuffle can involve two spills: one on the map side and one on the reduce side.
The consequences that make this worth knowing. Shuffles are where jobs fail, because they touch disk, network and memory at once. Skew is fatal here specifically: one reducer fetching a partition ten times the size of the others takes ten times as long and the stage waits for it. And the way to make a job faster is almost always to shuffle less — filter and aggregate before the shuffle, use a broadcast join to avoid one entirely, or partition the data on disk so the shuffle is unnecessary.
The answer most people give
"The nodes send rows to each other over the network." Rows are written to local disk first and then fetched. That materialisation is why a shuffle is expensive and why it survives a failed reducer without redoing the map side.
They’ll ask next
Why does raising the partition count from 200 to 20,000 sometimes make a job slower?
Hash vs sort aggregationJoin algorithmsWork partitioning & skew
What happens inside the hash table an engine builds for a join or an aggregation, and why does its performance fall off a cliff rather than degrading smoothly?
Why they ask this
It probes one level deeper than most candidates go, and the cliff — from cache-resident to cache-missing — explains a class of mysterious slowdowns.
Say this
Keys are hashed to buckets and collisions are resolved by probing or chaining. Performance falls off a cliff at two points: when the table stops fitting in cache, and when it stops fitting in memory.
The reasoning
The mechanics: hash the key, index into an array of buckets, and resolve collisions — open addressing probes nearby slots, chaining follows a linked list. Open addressing is what analytical engines use, because probing adjacent slots is cache-friendly where chasing pointers is not. The load factor is watched, and exceeding it triggers a resize, which rehashes everything.
The first cliff is cache. While the table fits in L2 or L3, a probe is a few nanoseconds. Once it exceeds the last-level cache, most probes become main-memory accesses at roughly a hundred nanoseconds, and since the access pattern is random by construction, prefetching cannot help. The same code gets an order of magnitude slower with no change in complexity — which is why measured performance disagrees with the `O(1)` on the whiteboard.
The second cliff is memory: exceed the budget and the engine spills, converting an in-memory operation into a partitioned, disk-based one.
This is why engines partition proactively. Radix-partitioning the input into pieces whose hash tables each fit in cache turns one cache-missing table into many cache-resident ones, and the extra pass over the data is repaid several times over. It is the same partition-then-conquer structure as spilling and as the bounded-memory dedup — the recurring answer whenever a structure does not fit a level of the hierarchy.
The answer most people give
"Hash lookups are O(1), so size does not matter." The constant is not constant. It changes by an order of magnitude at the cache boundary and again at the memory boundary, and those two cliffs are what the performance graph actually shows.
They’ll ask next
Why does partitioning the input first make the total work smaller, when it adds a whole extra pass?
A billion-row fact table joins a dimension. When does the engine broadcast, and what goes wrong when it decides to and should not have?
Why they ask this
It is the single most consequential plan decision in distributed SQL, and the failure mode — an out-of-memory error on the driver — is distinctive enough to be diagnosable from the symptom.
Say this
It broadcasts when the small side is estimated to fit under a threshold, shipping a copy to every node so the large side never moves. When the estimate is wrong, the small side is collected into one process and the job dies with an out-of-memory error before any join happens.
The reasoning
A shuffle join repartitions *both* sides by the join key so matching keys meet on the same node. That moves the billion-row side across the network, which is the dominant cost. A broadcast join instead sends the whole small side to every node; the large side stays where it is and is joined locally. When the small side really is small, this is enormously cheaper — no shuffle at all.
The decision comes from an estimated size compared against a threshold. Estimates come from statistics, and statistics go stale or were never collected, so the engine can believe a table is ten megabytes when it is ten gigabytes. It then collects that side into a single process to build the broadcast — and that is where it fails, with an out-of-memory error that names the driver or coordinator rather than the join.
The signature is worth memorising because it is unambiguous: a job that fails during collection, before any real work, with memory pressure in the coordinator. That is a broadcast of something too large, and the fix is either to refresh statistics, lower the threshold, or hint the plan away from it.
The subtler failure is broadcasting something that is small but joined against a *filtered* large side that would have been cheap to shuffle anyway — you pay to ship the small side to a hundred nodes that each use a fraction of it. And the reverse: a shuffle chosen because the estimate said 'too big', where the small side was actually tiny, gives you a full shuffle of a billion rows for nothing. Both are estimate failures, which is why the practical advice is that keeping statistics current is worth more than any individual hint.
The answer most people give
"Broadcast is always better for a small dimension." It is better when the estimate is right. The failure mode is not a slow query but a dead coordinator, and it happens before the join starts.
They’ll ask next
You see an OOM in the coordinator seconds after the job starts. What is your first hypothesis?
Both sides of a join are already sorted on the join key. What changes, and why do storage formats care about this?
Why they ask this
It connects physical layout to plan choice, which is the level at which a senior engineer designs a table rather than just querying it.
Say this
The sort-merge join's expensive half disappears — it becomes a single linear sweep of both inputs with almost no memory. That is why writing data sorted on its common join key is a design decision, not a detail.
The reasoning
Sort-merge join costs two sorts plus a merge. If the inputs arrive sorted, the sorts are free and only the merge remains: walk both cursors forward, emit matches. Memory is a handful of rows, so it never spills, and it scales to inputs far larger than any hash table could hold.
That is why bucketed or clustered tables exist. Writing both tables partitioned and sorted on `customer_id` means the engine can join them without shuffling *or* sorting — matching buckets are already colocated and ordered. The cost has been paid once at write time and is reused by every query.
The same property helps beyond joins: a `GROUP BY` on the sort key becomes a streaming sweep rather than a hash table, range predicates prune effectively because min/max statistics are tight, and run-length encoding becomes useful. One decision improves four things, which is what makes it worth making deliberately.
The costs to be honest about: maintaining sort order on write is expensive, especially for incremental appends, and you can only be sorted on one thing. Choosing the sort key is choosing which query shape to privilege. And it decays — a table sorted at creation and appended to for six months is no longer sorted, which is what compaction and clustering maintenance exist to fix, and why 'we sorted it once' is not a durable answer.
The answer most people give
"The engine would still hash join because hash is faster." With sorted inputs the merge needs no build side and no memory. Engines actively look for this, which is the entire reason bucketing exists as a feature.
They’ll ask next
You can sort the table on one column. How would you choose which?
An optimizer decides between a broadcast and a shuffle from an estimated row count. Where does that number come from, and why is it so often wrong?
Why they ask this
Almost every bad plan traces back to a bad estimate, so knowing the source of the number is what lets a candidate fix plans rather than fight them with hints.
Say this
From table statistics — row counts, distinct counts, histograms, min/max — propagated through the plan by assumptions. It goes wrong because the assumptions (uniformity, independence, containment) rarely hold on real data.
The reasoning
The base is collected by analysing the table: total rows, per-column distinct counts (often themselves estimated with a sketch), null fractions, min and max, and sometimes a histogram of the value distribution. Everything above the scan is *derived* from those by rules.
The rules encode assumptions, and each one is a place error enters. Uniformity: a filter on one of ten distinct values is assumed to keep a tenth of the rows, which is wildly wrong when the distribution is skewed. Independence: two predicates are assumed to multiply, so `country = 'FR' AND city = 'Paris'` is estimated far smaller than it is, because the columns are correlated. Containment for joins, which assumes the smaller side's keys all appear in the larger.
Errors then compound multiplicatively up the plan. A join whose inputs are each estimated three times too small produces an output estimate that is off by an order of magnitude, and the plan for the *next* join is chosen from that. This is why bad plans usually appear in deep queries rather than simple ones, and why the fix is often to break a query up so the engine can see real intermediate sizes.
What to do in practice: keep statistics fresh, since stale stats on a growing table are the single most common cause; add multi-column statistics where the engine supports them, which directly addresses the independence assumption; materialise a problem intermediate so its true size is known; and only then reach for hints. Adaptive execution — re-planning mid-query once real row counts are known, as Spark's AQE does — is the systemic answer, and knowing it exists is a good signal.
The answer most people give
"The optimizer counts the rows." It estimates them from summary statistics, and everything above the leaf scans is derived through assumptions rather than measured. Counting would cost as much as running the query.
They’ll ask next
Two correlated predicates give an estimate a hundred times too small. Which assumption failed, and what fixes it?
A table is one terabyte in two million files. The same terabyte in two thousand files reads far faster. Where does the time actually go?
Why they ask this
Everyone knows small files are bad; the question is whether the candidate can name the four separate costs, because the fix depends on which one dominates.
Say this
Per-file overhead — listing, opening, reading the footer, and a task per file — none of which scales with the file's size. Two million files is two million round trips before a single useful byte is read.
The reasoning
Listing comes first. Object stores paginate listings at a thousand keys, so enumerating two million files is two thousand sequential API calls before planning can even start. On a partitioned table with many directories this is often the largest single component, and it happens every query.
Then per-file open cost. A columnar file has a footer with its schema and statistics that must be read before any data — so each file costs at least one extra round trip. At tens of milliseconds each, two million files is hours of latency that no amount of parallelism inside a file can help.
Then scheduling. Engines assign work per file or per row group, so two million files becomes two million tasks. Task scheduling overhead is milliseconds each, and tasks that do a few kilobytes of work are almost entirely overhead.
Then compression and encoding quality, which is the one people forget: a file too small to fill a row group compresses badly and its statistics are useless for pruning, because a min/max over a handful of rows rarely excludes anything. So small files also defeat the pushdown that would have saved you.
The fixes follow from which cost dominates: compaction to rewrite many small files into few large ones, which is the general answer; larger write batches or fewer output partitions so they are not created in the first place; and coarser partitioning, since partitioning by a high-cardinality column is the usual root cause. The target is normally a few hundred megabytes per file — big enough to amortise the overhead, small enough to parallelise.
The answer most people give
"It is a throughput problem, so add more workers." The costs are per file and mostly latency, not bandwidth. More workers means more parallel listing and opening, which the object store then rate-limits.
They’ll ask next
Which of those four costs does compaction fix, and which does it not?
A query filters on one column and returns twenty. Why would an engine read the filter column first and the other nineteen afterwards?
Why they ask this
It is a real technique with a clear payoff, and understanding it requires holding the columnar layout and the selectivity of the predicate in mind at once.
Say this
Because the filter usually removes most rows, and reading nineteen columns for rows you are about to discard is wasted. Reading the predicate column first gives a set of surviving positions, and only those are fetched from the rest.
The reasoning
Early materialisation reads every requested column for every row in the block, assembles the rows, then filters. Late materialisation reads only the predicate column, evaluates it to produce a selection vector — a list of positions that survived — and then reads the other columns at just those positions.
The saving scales with selectivity. A predicate keeping one percent of rows means the other nineteen columns are read for one percent of the data, which is a hundred-fold reduction in decompression work. When the predicate keeps most rows the technique buys nothing and the positional access can be slower than a sequential scan, which is why engines choose adaptively rather than always doing it.
It composes with the encodings. A dictionary-encoded column can be filtered by comparing dictionary codes rather than decoded strings, so the predicate is evaluated on integers and the strings are never materialised for rejected rows at all. That is why the technique and the encoding are designed together.
The limit is random access cost. Fetching scattered positions from a compressed column means decompressing the blocks that contain them, so if the survivors are spread evenly across every block you decompress everything anyway. Clustering the data on the filter column concentrates survivors into fewer blocks — which is the same reason sort order helps zone maps, arriving from a different direction.
The answer most people give
"It reads the columns in the order they appear in the SELECT." Column order in the query is irrelevant. The order is chosen from the predicate and its estimated selectivity.
They’ll ask next
The predicate keeps 90% of rows. Is late materialisation still a win?
`SELECT DISTINCT customer_id` over ten billion rows. What does the engine do, and what makes `COUNT(DISTINCT ...)` a different problem?
Why they ask this
It links deduplication to aggregation mechanics, and the count variant is where sketches enter — so it sets up the trade-off the whole subject turns on.
Say this
`DISTINCT` is a group-by with no aggregate: hash or sort, with the same memory profile. `COUNT(DISTINCT ...)` additionally cannot be computed from partial results without shipping the values themselves, which is why it does not distribute cheaply.
The reasoning
`SELECT DISTINCT x` is exactly `GROUP BY x` with no aggregate function, and it is executed the same way — a hash table keyed on the distinct values, or a sort followed by a sweep that emits on change. Memory is the number of distinct values, so distinct over a near-unique column is the expensive case.
Distributed, it is a two-stage operation: deduplicate locally on each node to shrink what moves, shuffle by a hash of the value so that equal values meet, then deduplicate again. The local pre-pass is what makes it affordable, and it works because distinctness is decomposable — the union of per-node distinct sets contains the global distinct set.
`COUNT(DISTINCT x)` is different because counts are not decomposable the same way. You cannot add per-node distinct counts — the same customer appearing on two nodes would be counted twice — so you must ship the values, not the counts. That is the fundamental reason a `COUNT(DISTINCT)` across a large cluster is so much more expensive than a `COUNT(*)`, and why multiple `COUNT(DISTINCT)`s in one query are worse still, since each needs its own shuffle.
Which is exactly the opening a sketch fills: HyperLogLog registers *are* mergeable, so each node builds a sketch, the sketches are combined by taking the maximum per register, and the count comes out of the merged sketch. You give up exactness and gain a computation that distributes in constant space. That trade — mergeability bought with approximation — is the recurring shape behind every sketch in this subject.
The answer most people give
"COUNT(DISTINCT) is just DISTINCT then COUNT, so the cost is the same." The cost is the same *locally*, and distributed it is the difference between shipping counts and shipping values. That is what makes it the classic query to replace with a sketch.
They’ll ask next
Why can HyperLogLog sketches be merged when distinct counts cannot?
`ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY ts)` over a billion rows. What does the engine actually do?
Why they ask this
Window functions are everywhere in analytics SQL and are frequently the most expensive operator in a plan, and the reason is the shuffle-and-sort they imply.
Say this
It shuffles by the partition key so each partition is on one node, sorts within each partition by the order key, then sweeps assigning numbers. It is a shuffle plus a sort — which is why it is usually the expensive step.
The reasoning
The `PARTITION BY` becomes a shuffle: rows are repartitioned by a hash of `customer_id` so every row for a customer is colocated. The `ORDER BY` becomes a sort within each partition. Only then can the function be evaluated, sweeping the sorted rows and assigning numbers.
So the cost is a shuffle plus a per-partition sort, and both can spill. A window over a partition key with few distinct values is the pathological case: a thousand customers over a billion rows means a thousand partitions, one node gets an enormous one, and that partition must be sorted in its entirety. Window functions are therefore acutely sensitive to skew, more so than a plain aggregation, because a partition cannot be split across nodes at all.
Frames matter for the cost of the sweep. `ROW_NUMBER` and running aggregates over an unbounded-preceding frame are a single forward pass with constant state. A sliding frame like `ROWS BETWEEN 100 PRECEDING AND CURRENT ROW` needs a buffer of that width. `RANGE` frames on a value rather than a row count need a search per row, and are considerably more expensive than the `ROWS` equivalent people usually mean.
The optimisation that follows: consecutive window functions sharing the same `PARTITION BY` and `ORDER BY` are evaluated in one shuffle-and-sort, so writing several windows with an identical `OVER` clause is nearly free compared to writing them with slightly different ones. And if the table is already partitioned and sorted on those columns on disk, the engine can skip both steps — which is the sorted-input argument again.
The answer most people give
"It scans the table and numbers the rows." It cannot number a partition until every row of that partition is together and ordered, which requires a shuffle and a sort before any numbering happens.
They’ll ask next
Two window functions differ only in their ORDER BY. What does that cost you?
Streaming windows & watermark semanticsWatermarking & progress tracking (scalar vs ledger)Out-of-order & late arrival
A streaming job computes a five-minute tumbling count per key. What does it hold in memory, and when is it allowed to let go?
Why they ask this
It connects windowing to watermarks, which is the mechanism the whole streaming half of this subject depends on, and 'when can state be released' is the question that decides whether a job survives a week.
Say this
One accumulator per open window per key. A window can be released once the watermark passes its end plus any allowed lateness — until then it must stay, because a late event could still change it.
The reasoning
State is `keys × open windows`. Tumbling windows keep that small because each key has one open window at a time; sliding windows multiply it by the overlap factor — a five-minute window sliding every minute means five open windows per key — and session windows are the worst, because a session's end is not known until a gap has elapsed and two sessions can merge when a late event bridges them.
Release is governed by the watermark, which is the job's assertion that no event older than time T will arrive. Once the watermark passes a window's end plus `allowedLateness`, that window can be emitted and its state dropped. That is the entire purpose of a watermark: it is the mechanism that makes unbounded state bounded.
Which is why a watermark that cannot advance is a memory leak, not just a latency problem. If one partition stops producing events, the watermark — usually the minimum across partitions — freezes, no window closes, and state grows until the job dies. The symptom is a streaming job with rising memory and no visible errors, and the cause is an idle partition rather than anything about the data volume.
The trade-off to state explicitly: longer allowed lateness means more correct results and proportionally more state retained. Shorter means less memory and more events arriving after their window has closed, which then need a side output or a batch reconciliation to recover. That decision is a data-correctness decision with a memory price, and it belongs to whoever owns the number, not to whoever tunes the cluster.
The answer most people give
"It holds the last five minutes of events." It holds accumulators, not events — one per open window per key — which is far smaller. What decides its lifetime is the watermark, not the wall clock.
They’ll ask next
One Kafka partition goes idle. What happens to the watermark, and then to memory?