Data Processing Algorithms Complexity & Trade-off Analysis
What does it cost, and what does it give up. Exact counts against a sketch that is one percent wrong for a thousandth of the memory, a full scan against an index that has to be maintained, accuracy against recovery time.
What each sketch gives up and what it buys, with the error measured against the formula that predicts it rather than asserted.
When approximation is and is not acceptable
3
The decision is about the consequence of being wrong, not about the size of the error — and it belongs to whoever owns the number.
What things actually cost
7
Memory against passes, index against scan, CPU against bytes. The constants matter more than the exponents at the sizes real systems run at.
Accuracy against recovery
4
Checkpoint frequency, retention and freshness are all the same trade: how much work you are willing to redo, and how stale you can afford to be.
Evergreen · asked verbatim
2
The flat form, in the words interviewers actually use. Same ground as the questions above, asked as recall rather than as a situation.
01 / 22
HyperLogLogCardinality estimationHash vs sort aggregation
You need the distinct user count over a billion events. Exact counting needs tens of gigabytes. What does HyperLogLog give you, and how wrong is it?
Why they ask this
The canonical exact-versus-approximate question. The interviewer wants the memory figure and the error figure, and most candidates can describe the structure without being able to say either.
Say this
A fixed-size array of registers — 16 KB for 16,384 of them — with a standard error of about 1.04 divided by the square root of the register count. At that size, roughly 0.8%, and the memory does not grow with the data at all.
The reasoning
The mechanism: hash each item, use the first `p` bits to choose a register, and record in that register the longest run of leading zeros seen in the rest of the hash. A long run is unlikely, so seeing one implies many distinct values passed through. The harmonic mean across registers, scaled by a bias constant, gives the estimate.
The properties that matter operationally. Memory is fixed at `2^p` registers and is completely independent of cardinality — the same 16 KB counts a thousand items or a billion. Error is `1.04/sqrt(m)`, so it improves as the square root of memory: four times the memory halves the error. And it is *mergeable* — combine two sketches by taking the per-register maximum — which is what lets it be computed per node, per partition or per day and then combined without re-reading anything.
The measurements beside this are the answer to 'how wrong': at precision 16 the estimate of one million distinct is 1,000,010, an error of 0.00%, against a predicted standard error of 0.41%. At precision 10 — one kilobyte — it is 0.03% against a predicted 3.25%. Note that the measured errors are inside the predicted band rather than equal to it: the prediction is a standard error, so any single measurement is a draw from a distribution, and quoting one lucky run as 'the accuracy' is the mistake to avoid.
Mergeability is the property worth emphasising, because it is what makes the sketch structurally better and not merely smaller. Exact distinct counts cannot be combined — you cannot add two nodes' counts without double-counting shared users — so exact `COUNT(DISTINCT)` requires shipping values. Sketches turn a shuffle-heavy operation into one that distributes in constant space, and daily sketches can be rolled up into a monthly figure that never required the month's data.
See it run on CPython 3.12
One million distinct keys, counted exactly and estimated, CPython 3.12.
"""HyperLogLog: 1.04/sqrt(m) is a promise. This measures whether it is kept."""
import hashlib
from math import log, sqrt
class HyperLogLog:
def __init__(self, precision):
self.p = precision
self.m = 1 << precision
self.registers = bytearray(self.m)
def add(self, item):
digest = hashlib.blake2b(item.encode(), digest_size=8).digest()
h = int.from_bytes(digest, "big")
index = h >> (64 - self.p) # first p bits pick the register
rest = (h << self.p) & ((1 << 64) - 1) # the rest decides the run length
rank = 1
while rest and not rest >> 63:
rank += 1
rest = (rest << 1) & ((1 << 64) - 1)
self.registers[index] = max(self.registers[index], rank)
def estimate(self):
alpha = 0.7213 / (1 + 1.079 / self.m)
harmonic = sum(2.0 ** -r for r in self.registers)
raw = alpha * self.m * self.m / harmonic
zeros = self.registers.count(0)
if raw <= 2.5 * self.m and zeros:
return self.m * log(self.m / zeros) # small-range correction
return raw
TRUE = 1_000_000
items = [f"user-{i}" for i in range(TRUE)]
print(f"{'precision':>9} {'registers':>9} {'bytes':>7} {'expected':>9} {'estimate':>12} {'error':>8}")
for precision in (10, 12, 14, 16):
hll = HyperLogLog(precision)
for item in items:
hll.add(item)
estimate = hll.estimate()
expected = 1.04 / sqrt(hll.m)
error = abs(estimate - TRUE) / TRUE
print(f"{precision:>9} {hll.m:>9,} {len(hll.registers):>7,} {expected:>8.2%} {estimate:>12,.0f} {error:>7.2%}")
print()
print(f"exact set of {TRUE:,} strings would need roughly {TRUE * 40 // 1024 // 1024} MB")
Prints
precision registers bytes expected estimate error
10 1,024 1,024 3.25% 1,000,275 0.03%
12 4,096 4,096 1.62% 1,010,757 1.08%
14 16,384 16,384 0.81% 993,698 0.63%
16 65,536 65,536 0.41% 1,000,010 0.00%
exact set of 1,000,000 strings would need roughly 38 MB
The answer most people give
"It is accurate to about 2%." The error is a function of the register count and is a standard error rather than a bound — an individual estimate can be further out, and the honest statement names the precision and the distribution.
They’ll ask next
You need distinct users for the month. Why is that nearly free with sketches and expensive without?
Count-Min SketchTop-k / heavy hittersCardinality estimation
Count-Min Sketch estimates the frequency of any key in fixed memory. What is the error, and why is it fine for heavy hitters and useless for the tail?
Why they ask this
The one-directional error and its concentration on the tail is the defining property, and it decides which questions the sketch may be used for.
Say this
It never underestimates, and its overestimate is roughly a fixed absolute amount driven by collisions. That is negligible against a count of 200,000 and overwhelming against a count of 10.
The reasoning
The structure: `d` rows of `w` counters, each row with its own hash. To add a key, increment one counter per row. To estimate, take the *minimum* across the rows — because every counter the key touched includes its true count plus whatever else collided there, so the smallest is the least contaminated. Hence never below the truth, and usually above it.
The error is additive rather than proportional, which is the whole point. The measured run over two million events in 10,240 counters shows the top key estimated at 200,337 against a true 200,000 — 0.2% — and a tail key estimated at 165 against a true 10, which is sixteen times too high. Same absolute error of a few hundred, utterly different relative error.
So the sketch is fit for questions about the head and unfit for questions about the tail. Heavy hitters, top-k, 'is this key hot' — fine. 'How many times did this rare key appear' — meaningless. And a question people get wrong: you cannot use it to find keys that appear *exactly once*, because the overestimate makes rare keys look common.
Sizing: width controls the error and depth controls the confidence — formally the error is bounded by `e/w` times the total count with probability `1 - e^-d`. Practically, depth of four or five is plenty and width is the dial you turn. It is mergeable like HyperLogLog, by summing the tables, which again is what makes it usable distributed. And the alternative worth naming is Space-Saving, which is designed specifically for heavy hitters and gives tighter guarantees for that narrower question.
See it run on CPython 3.12
A Zipf-shaped stream of 20,000 keys, seeded, CPython 3.12.
The answer most people give
"It is accurate to within a few percent." It is accurate to within a few hundred *counts*. For a key seen ten times that is a factor of sixteen, which is the difference between the sketch being useful and being nonsense.
They’ll ask next
Could you use it to find keys that appeared exactly once?
Reservoir sampling claims every item has an equal chance of being kept, including the first and the last. Why is that true, and how would you convince yourself?
Why they ask this
The uniformity is counterintuitive — the first item is kept immediately and the last is almost always rejected — so it tests whether the candidate can reason about the telescoping probability or only recite the algorithm.
Say this
The first item is kept immediately and then survives many chances to be evicted; the last is unlikely to be selected but never risks eviction. The two effects cancel exactly, leaving every item at k/n.
The reasoning
The argument: item `i` (zero-based, beyond the first k) enters with probability `k/(i+1)`. Once in, it survives step `j` with probability `1 - 1/(j+1)`, because a new item is selected with probability `k/(j+1)` and evicts a specific slot with probability `1/k`. Multiplying the survival terms from `i+1` to `n-1` telescopes, and the product with the entry probability is exactly `k/n` for every `i`.
The measurement is the part worth doing, because the argument is easy to believe and easy to get subtly wrong in code. Two hundred thousand trials over twenty items with a reservoir of five: item 0 kept 50,173 times, item 19 kept 50,297, expected 50,000, largest deviation under 1%. The first and last items — the pair everyone expects to differ — agree.
The bug the measurement catches is the off-by-one: drawing `randrange(i)` instead of `randrange(i + 1)` makes early items over-represented. The output still looks like a plausible sample, so no test that inspects a single run will find it, and only the frequency distribution over many trials shows it.
The trade being made: one pass, `k` items of memory, no advance knowledge of `n`, and a genuinely uniform sample. What you give up is any control over *which* items — you cannot ask for a stratified or consistent-by-key sample, which is what hash-based sampling gives instead. Knowing that the two techniques answer different questions, and that reservoirs merge across shards with appropriate weighting, is the full answer.
See it run on CPython 3.12
200,000 independent trials, seeded, CPython 3.12.
The answer most people give
"The first items are more likely to be kept, since they start in the reservoir." They start in it and face every subsequent eviction. The measured frequencies for item 0 and item 19 differ by 0.2%, which is noise.
They’ll ask next
You draw randrange(i) instead of randrange(i + 1). What does the distribution look like?
Bloom filters trade memory for a false-positive rate. Quantify that trade and say where the sweet spot is.
Why they ask this
It tests whether the candidate can reason about the shape of the curve rather than recalling one number, and the exponential improvement is what makes the sizing decision easy.
Say this
The false-positive rate falls exponentially in bits per key: about 15% at four bits, 2% at eight, 0.3% at twelve, 0.05% at sixteen. Ten bits per key for 1% is the usual landing point, because further improvement gets cheap and then stops mattering.
The reasoning
The measured table shows the curve: at four bits per key the rate is 14.7%, at eight it is 2.2%, at twelve 0.3%, at sixteen 0.05%. Each additional four bits divides the rate by roughly seven. Memory is linear and accuracy is exponential, which is an unusually favourable shape and the reason Bloom filters are used so widely.
Consequently the decision is not a fine balance. Going from four bits to eight is dramatic and costs 50 KB per hundred thousand keys; going from twelve to sixteen improves a rate that is already negligible. The sweet spot is wherever the false-positive *cost* stops mattering, and for most uses — skipping a remote lookup, pre-filtering a join — that is around 1%, or ten bits per key.
The number of hashes matters less than people expect, and the optimum is `(m/n)·ln2` — about 5.5 for eight bits per key. Being off by one costs little; being far off costs a lot, since too few hashes underuses the bits and too many saturates them.
What actually decides the sizing is the cost asymmetry. A false positive costs one wasted lookup; the filter saves you all the true negatives. So if the lookup is a remote call, a 1% rate means 1% of the calls you would otherwise have made — a hundredfold reduction — and paying for 0.1% buys you very little more. The failure to plan for is exceeding the `n` you sized for: the rate degrades silently as the filter fills, so a filter built for a hundred million keys and fed three hundred million is far worse than its stated rate and nothing will tell you.
See it run on CPython 3.12
100,000 members and 100,000 non-members, blake2b, CPython 3.12.
The answer most people give
"Use as many bits as you can afford." The curve flattens — beyond about twelve bits per key you are spending memory to improve a rate nothing downstream can distinguish from zero.
They’ll ask next
You sized for 100 million keys and inserted 300 million. What is the rate now, and would you notice?
Give me the rule for when you may use an approximate answer and when you may not.
Why they ask this
It is the judgment question the whole category builds to, and the good answer is about consequence and reconcilability rather than about the size of the error.
Say this
Approximation is acceptable when the answer informs a decision that a small error would not change, and unacceptable when the number is itself an obligation — money owed, a regulatory count, or anything that must reconcile with another system.
The reasoning
The wrong framing is 'how big is the error'. One percent on a dashboard trend is invisible; one percent on a payout is fraud. The question is what the number is *for*, and specifically what changes if it is wrong by the error bound.
Approximation is fine for: trend and monitoring, capacity planning, top-k and heavy hitters, anomaly detection, anything read as a shape rather than a value, and any intermediate used to decide whether to do the expensive exact computation. In all of these a 1% error changes no decision anyone takes.
Approximation is not acceptable for: money, anything a regulator counts, anything that must tie out against another system, and anything a user sees about their own data — a customer who counts their own eleven orders will not accept eleven-point-one. That last category is underrated and is where approximation most often causes trouble, because the individual scale is where relative error is worst.
The middle ground worth proposing rather than choosing a side: serve the approximate answer for latency and reconcile against an exact computation on a slower cadence, with the discrepancy monitored. That is the same fast-path-plus-correction shape as the late-data design, and it gets you a fast dashboard and a number that is provably close.
Two caveats to close on. Approximations compound — a sketch feeding a sketch has error nobody has bounded — so the boundary should be as close to the consumer as possible. And an approximate number must be *labelled*, because the real failure is not the 1%, it is that six months later someone builds a billing process on a figure nobody told them was estimated.
The answer most people give
"Use exact whenever you can afford it." That gives up mergeability and constant memory even where the error could not possibly matter — and it is how a `COUNT(DISTINCT)` ends up shuffling terabytes to produce a dashboard tile.
They’ll ask next
You serve an approximate figure and reconcile weekly. What has to be true about how it is labelled?
External merge sort & spillingHash vs sort aggregationCardinality estimation
Sorting two terabytes: how does the cost change as you add memory, and why is the answer not linear?
Why they ask this
It is the clearest example of a stepped cost function in data processing, and it corrects the common intuition that resources trade smoothly against time.
Say this
Cost is passes, and passes are `1 + ceil(log_fanin(runs))`. Memory only helps when it removes a whole pass, so the curve is a staircase — sixteen gigabytes and four gigabytes cost the same.
The reasoning
The measured table makes it concrete. Each pass reads all two terabytes and writes all two terabytes, so a pass costs four. At one, four and sixteen gigabytes the sort takes three passes — twelve terabytes moved. At sixty-four and two hundred and fifty-six it takes two — eight. Between the steps, extra memory changes the run size and not the number of merge rounds, so it buys nothing at all.
That has a direct budgeting consequence. Asking for more memory is only worth it if you can compute that it crosses a step — otherwise you are paying for a resource that will not change the runtime, which is a very common and entirely invisible waste.
Fan-in is usually the better lever, because it is the base of the logarithm. Doubling fan-in has the same effect on pass count as squaring the memory. Its limit is buffer size per open run: too high a fan-in means tiny reads and the disk stops being sequential, so there is an optimum rather than a maximum.
The general shape is worth carrying beyond sorting. Many data-processing costs are stepped rather than continuous — number of passes, number of shuffle stages, whether a hash table fits in cache, whether a broadcast is chosen. When someone reports that doubling a resource did nothing, a step function is usually why, and the useful question is which threshold they are between rather than how much more to add.
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. Between thresholds, additional memory changes the runtime by nothing measurable.
They’ll ask next
You can double the memory or double the fan-in. Which gives more, and what limits the second?
At what selectivity does an index stop being worth using, and why does the answer differ between a row store and a columnar warehouse?
Why they ask this
It tests understanding of random versus sequential access costs, and the columnar answer is different enough to catch people who learned the rule on OLTP databases.
Say this
In a row store, an index loses somewhere around 5–20% selectivity, because each match is a random read and sequential reads are far cheaper per row. In a columnar warehouse there is usually no such index — pruning by layout replaces it.
The reasoning
The row-store arithmetic: an index lookup gives you row locations, and fetching each is a random access. A full scan reads sequentially, which on any storage medium is dramatically cheaper per byte. So the index wins when it eliminates enough rows to offset the random-access penalty, and the crossover is typically a few percent to twenty percent depending on the medium and whether the index is covering. A covering index — one containing every column the query needs — avoids the row fetches entirely and changes the calculation completely.
Analytical columnar systems mostly do not offer that choice, and the reason is worth stating: they are designed for queries that touch large fractions of a table, where an index would lose by definition. What replaces it is pruning by *layout* — partitioning, min/max statistics per row group, and clustering — which skips data in large contiguous units rather than locating individual rows.
That changes the design lever. In a row store you add an index and the layout is unchanged. In a warehouse you change the layout — partition by the column you filter on, sort by the column you range-scan — and every query benefits or does not depending on whether it matches. You can be sorted on one thing, so it is a choice about which query shape to privilege rather than something you can add for each query.
The costs on both sides: indexes must be maintained on every write and consume storage, which is why a table with many indexes is slow to load. Clustering must be maintained too, and decays as data is appended, which is what compaction and re-clustering exist for. Neither is free, and 'add an index' as a reflex is how an ingest pipeline becomes the bottleneck.
The answer most people give
"An index is always faster for a filtered query." Past a few percent selectivity, the random reads cost more than a sequential scan — which is why optimizers routinely ignore an available index.
They’ll ask next
Why can a covering index change that crossover point so much?
Would you store a hot table with zstd at maximum level, snappy, or uncompressed? What decides it?
Why they ask this
It is a real, frequently-made decision, and the right answer depends on whether the workload is I/O-bound or CPU-bound — which is a question people rarely ask before choosing.
Say this
Whichever makes the bottleneck smaller. If reads are I/O-bound, heavier compression is faster overall because fewer bytes move. If they are CPU-bound, light compression or none wins, because decompression is competing with the query.
The reasoning
The framing: compression trades CPU for bytes. Whether that is a win depends entirely on which of the two you are short of. On a system reading from object storage over a network, bytes are the constraint and a heavier codec genuinely makes queries faster despite doing more CPU work. On a system with data in page cache and a CPU-heavy query, decompression competes with the work and light compression wins.
The asymmetry that decides most real cases is that compression happens once and decompression happens on every read. So a slow, high-ratio codec at write time is often correct — you pay once for a saving realised thousands of times. Zstd is popular precisely because its decompression is fast across levels, so you can raise the level for a better ratio without paying for it on every read; gzip is the opposite, and is why it has fallen out of favour despite decent ratios.
Splittability matters as much as ratio, and is easy to forget: a single gzip file cannot be split, so it is read by one task no matter how large. Snappy and zstd within a columnar format are block-compressed and remain splittable, which is why format-level compression beats compressing the whole file.
And the encodings come first. The measurements in Mechanics show run-length collapsing a sorted column to nothing while a general codec on the same data does far less — the ratio comes mostly from the encoding and the sort order, with the codec applied on top. So the sequence is: choose the sort order, let the encodings work, then pick a codec that decompresses cheaply. Choosing a heavy codec to compensate for an unsorted table is paying CPU forever for something a sort would have given free.
The answer most people give
"Maximum compression, storage is expensive." Storage is usually the cheapest thing in the system. What you are actually trading is read CPU against bytes moved, and storage cost rarely decides it.
They’ll ask next
Why does a single gzipped file behave so badly in a distributed engine?
Hash vs sort aggregationExternal merge sort & spillingJoin algorithms
An `O(n log n)` algorithm beats an `O(n)` one on your data. How is that possible, and what does it tell you about optimising data systems?
Why they ask this
It tests whether the candidate reasons about real machines rather than about asymptotics, which is the difference between a theoretically good design and a fast one.
Say this
Because constants and memory-hierarchy effects dominate at real sizes. A cache-friendly sequential `n log n` easily beats a random-access `n` that misses cache on every element.
The reasoning
The concrete case is sort-based versus hash-based aggregation. Hashing is `O(n)` and touches memory randomly; once the table exceeds last-level cache, most probes are main-memory accesses at a hundred nanoseconds and prefetching cannot help because the pattern is random by design. Sorting is `O(n log n)` and is almost entirely sequential, so it runs near memory bandwidth. At real sizes the log factor is around thirty and the per-access penalty is around a hundred, so the asymptotically worse algorithm wins.
The same reasoning explains why engines radix-partition before building hash tables — an extra `O(n)` pass, deliberately added, to make every subsequent table fit in cache. Adding work to reduce total time only makes sense once you are counting cache misses rather than operations.
The generalisation: in data processing the units that matter are bytes moved and where they are moved from. Sequential versus random, cache versus memory versus disk versus network — those span six orders of magnitude, and complexity analysis treats them all as one operation. That is why 'reads less data' beats 'does fewer operations' as a heuristic almost every time.
Which is not an argument for ignoring complexity. It correctly predicts what happens when the data grows tenfold, and a genuinely quadratic algorithm will lose eventually no matter how good its constants. The right posture is to use asymptotics to rule out the disasters and measurement to choose among the survivors — and to be suspicious of any performance claim, including your own, that has not been measured on realistic sizes.
The answer most people give
"Complexity analysis is what matters, the constants are noise." The constants here span six orders of magnitude between cache and network. At the sizes real systems run at, they routinely decide the winner.
They’ll ask next
Why would an engine deliberately add an extra full pass over the data?
Checkpointing costs time and reduces rework after a failure. How would you choose the interval?
Why they ask this
It is an optimisation with an actual answer rather than a preference, and deriving it from failure rate and cost is a good demonstration of quantitative reasoning.
Say this
Balance the cost of checkpointing against expected rework: roughly, checkpoint often enough that the overhead is a small percentage, and rarely enough that you are not spending the run checkpointing. Expected rework is about half the interval per failure.
The reasoning
The two terms. Overhead is `checkpoint_cost / interval` as a fraction of runtime. Expected rework is about half the interval, multiplied by the probability of a failure in that window. Minimising their sum gives an interval that grows with checkpoint cost and shrinks with failure rate — the same square-root-ish shape as many batching problems.
In practice you rarely compute it exactly; you bound it. Aim for checkpoint overhead of a few percent, and check that half an interval of rework is acceptable against the deadline. Those two constraints usually pin the interval within a small range without any calculus.
The failure rate is the term people ignore and it varies enormously. On spot or preemptible instances, interruptions are frequent and the interval should be short — the rework term dominates. On stable dedicated hardware, failures are rare and long intervals are fine. Running the same configuration on both is how a job that was well-tuned becomes badly tuned by being moved.
Two interactions. Checkpoint cost is proportional to state size, so as state grows the optimum interval lengthens — and if state is growing without bound, the checkpoint problem is a symptom rather than the thing to tune. And incremental checkpointing changes the arithmetic entirely by making the cost proportional to what changed rather than to total state, which usually means you can checkpoint far more often than before, so it is worth revisiting the interval after enabling it rather than leaving it where it was.
The answer most people give
"As often as possible, so nothing is lost." At a short enough interval the job spends most of its time checkpointing and never finishes, which loses everything rather than a little.
They’ll ask next
You move the job from dedicated instances to spot. Which term changed, and which way does the interval move?
Sketches are usually sold on memory. Argue that mergeability matters more.
Why they ask this
It is the deeper property and the one that changes system architecture rather than just resource usage, so it separates a candidate who has used sketches from one who has read about them.
Say this
Memory makes a computation possible on one machine; mergeability makes it decomposable across machines and across time. That turns a shuffle-heavy global operation into per-partition work plus a cheap combine.
The reasoning
A mergeable summary means `f(A ∪ B)` can be computed from `f(A)` and `f(B)` without revisiting the data. HyperLogLog merges by taking per-register maxima; Count-Min by summing tables; t-digest by combining centroids. Exact distinct counts have no such operation — you cannot combine two counts without knowing the overlap, which is why you must ship the values.
Distributed, that is the difference between a shuffle of every value and a shuffle of a handful of fixed-size sketches. `COUNT(DISTINCT)` across a cluster moves data proportional to cardinality; the sketch version moves 16 KB per partition regardless. That is an architectural change, not a memory optimisation.
Across time it is arguably more valuable. Store a daily sketch and you can answer monthly, quarterly or arbitrary-range distinct counts by merging the stored sketches — never re-reading the underlying events. Exact daily counts cannot be added into a monthly count at all, because a user active on twenty days would be counted twenty times. That single property is why sketches appear in analytics platforms far more than the memory argument alone would justify.
The caveat that makes it honest: merging composes error, though for these structures it composes well — merged HyperLogLog sketches have the same relative error as one built over the union, which is a genuinely strong property and not true of every summary. What does not merge safely is a sketch feeding another approximation, where nobody has bounded the combined error, so the rule is to keep approximations to one layer and as close to the consumer as you can.
The answer most people give
"They save memory." True and the smaller half. A structure that saved memory and could not be merged would still force a global shuffle, and would be useless for rolling daily figures up to monthly.
They’ll ask next
You store a daily HLL sketch. Why is the monthly figure nearly free, and what would exact daily counts give you?
Cardinality estimationTop-k / heavy hittersHash vs sort aggregation
You need p50, p95 and p99 latency over a billion events. Why is that harder than a mean, and what do you use?
Why they ask this
Percentiles are ubiquitous in monitoring and are genuinely harder to distribute than sums, and the non-averageable property is the insight.
Say this
A mean is decomposable — sums and counts add. A percentile is not: you cannot average per-node p95s. Use a mergeable quantile sketch such as t-digest or KLL, which keeps bounded state and merges correctly.
The reasoning
The exact answer requires ordering: the p95 is the value at position 0.95n in sorted order, so exactly it needs a sort or the full multiset. That is fine for a million values and not for a billion per node per minute.
The trap worth naming explicitly because it is committed constantly: averaging percentiles is meaningless. The mean of ten nodes' p95 values is not the global p95, and it is not an approximation of it either — it can be arbitrarily far off depending on how load is distributed. Any dashboard aggregating pre-computed per-instance percentiles is showing a number with no defined meaning.
Quantile sketches solve it. t-digest keeps clusters of values with higher resolution at the tails — deliberately, because p99 is what people care about and p50 is easy — and merges by combining centroids. KLL and the older Greenwald-Khanna give formal error bounds on rank rather than t-digest's empirical accuracy. All are mergeable, which is what makes per-node computation plus a combine correct rather than approximate-in-an-undefined-way.
The trade: error is in the *rank* rather than the value, so a t-digest p99 is really 'a value somewhere between the p98.9 and p99.1', which for latency monitoring is entirely adequate. Where it is not adequate is a hard SLA threshold with money attached, and there the answer is the same as elsewhere — serve the sketch for monitoring and compute exactly on a slower cadence for the number that is an obligation.
The answer most people give
"Compute the p95 on each node and average them." That produces a number with no interpretation. Percentiles are not decomposable, which is the entire reason quantile sketches exist.
They’ll ask next
Why do quantile sketches deliberately keep more resolution at the tails?
Streaming windows & watermark semanticsIncremental computation & delta detectionOut-of-order & late arrival
A dashboard could be updated every minute, every hour or nightly. How would you decide, and what does each cost?
Why they ask this
It forces the candidate to connect a business requirement to an architecture, and the honest answer resists the assumption that fresher is better.
Say this
Decide from the latency of the decision the data supports. Cost rises sharply and non-linearly with freshness, because at some point batch stops working and you are operating a streaming system instead.
The reasoning
The question to ask is what decision this data drives and how quickly someone acts on it. A weekly planning meeting does not benefit from minute-level freshness. A fraud check does. Most dashboards people ask to be real-time are looked at twice a day, and the honest conversation is about that rather than about the pipeline.
The cost curve is not smooth. Nightly is one batch job. Hourly is twenty-four times the runs, and each still amortises fixed startup over real work. Every minute usually means the batch model stops working entirely — startup overhead dominates, and you move to streaming, which is a different system with state, watermarks, checkpoints and an on-call burden. That transition is a step change in operational cost, not an increment.
The intermediate options worth proposing, because they often satisfy the requirement at a fraction of the cost. Incremental batch at fifteen-minute intervals gets you most of the way with batch's simplicity. A lambda-style split — a fast approximate view plus a nightly exact recompute — gives freshness where it matters and correctness where it matters. And serving a fresh partial answer for today plus exact history is often exactly what the dashboard needed.
The costs beyond compute that decide it in practice: streaming systems need people who can debug them at 3am, and correctness is harder because late data must be handled explicitly rather than by tomorrow's rerun. I would push back on a real-time requirement until someone names the decision that depends on the difference — and if they can, build it, because then it is worth the money.
The answer most people give
"Make it as fresh as possible, users prefer real-time." Users prefer it and rarely act on it, and the jump from hourly to real-time is usually a change of architecture rather than a change of schedule.
They’ll ask next
The requirement is 'real-time'. What question would you ask to find out what is actually needed?
Raw events cost money to store. How do you decide the retention, and what does a short one take away?
Why they ask this
Retention is treated as a storage-cost question and is really a recovery-capability question, which is the reframing the answer needs.
Say this
Retention bounds how far back you can reprocess. Cutting it saves storage and removes the ability to fix a bug retroactively, rebuild a derived table, or answer a question you had not thought of.
The reasoning
The reframing first: raw data is what every derived table can be rebuilt from. Delete it and every downstream table becomes the only copy of its own logic's output — so a transformation bug discovered in six months is unfixable for the period beyond retention, and a new metric can only be computed from the day you thought of it.
So the retention decision is really 'how far back must we be able to reprocess'. That is usually driven by how long a bug can plausibly go unnoticed — which for a rarely-checked metric is months — plus any regulatory obligation, plus the cost of losing the ability to answer new questions retroactively.
The cost side is less alarming than it sounds, and the tiering is the answer rather than deletion. Raw events compress extremely well, and cold object storage is very cheap. A common shape is hot storage for recent data, cold for the older tail, and deletion only where a regulation requires it — which usually means retention is bounded by policy rather than by cost.
Two related decisions. Deletion obligations under privacy regulation cut the other way and are non-negotiable, so retention has a legal ceiling as well as an operational floor. And the derived tables should be treated as disposable rather than precious: if raw is retained and the transformations are in version control, a derived table is a cache. Teams that back up derived tables and delete raw have it exactly backwards, and it is worth saying so.
The answer most people give
"Keep it for thirty days, that is plenty for debugging." Thirty days is fine for debugging a job and useless for fixing a transformation bug nobody noticed for a quarter — which is the failure retention actually protects against.
They’ll ask next
You must delete a user's raw events on request. What does that do to your ability to rebuild?
You are asked to halve a pipeline's cost. Where do you look, and what would you refuse to trade?
Why they ask this
The synthesis question. It tests whether the candidate measures before optimising and whether they can identify the things that are not negotiable.
Say this
Measure per-stage first, because cost is always concentrated. Then attack bytes read, redundant recomputation and idle capacity — and refuse to trade away correctness, reproducibility and recoverability.
The reasoning
Measure first and expect concentration: in every pipeline I have looked at, a handful of jobs are most of the bill. Per-stage runtime and bytes scanned, attributed back to specific tables, turns 'the pipeline is expensive' into 'these four models are 70% of it', and the conversation becomes tractable.
Then the levers, roughly in order of return. **Read less** — partition and cluster so pruning works, project only needed columns, filter before joining and before shuffling. This is almost always the biggest single win because it compounds through every downstream stage. **Recompute less** — incremental instead of full rebuild where the aggregate decomposes, and stop rebuilding tables nobody reads. **Store less hot** — tier cold data, compact small files, and re-examine anything held in an expensive tier for convenience.
Then the shape of the spend: idle capacity is the quiet one. A fleet sized for the 9am peak is idle sixteen hours a day, and staggering schedules plus autoscaling can be a large saving without touching any pipeline. And a surprising fraction of the bill is often CI or ad-hoc queries rather than production, which nobody attributes.
What I would not trade: correctness, obviously; reproducibility, because a cheaper pipeline whose numbers cannot be re-derived costs more the first time they are questioned; recoverability, meaning idempotence and retention, because giving those up saves money until the incident that costs more than a year of the savings; and the tests and reconciliation checks, which are usually a rounding error in cost and the only reason anyone would notice if a cost optimisation broke something.
The one to be explicit about is approximation. Replacing exact aggregates with sketches is often a large saving and it changes what the numbers mean — so it is a decision for whoever owns the number, and it needs the result labelled as estimated. A cost optimisation that quietly makes the figures approximate is not an optimisation, it is a change of contract.
The answer most people give
"Reduce the cluster size and see what breaks." It is an experiment with production as the subject, and it does nothing about the four models that are most of the cost. Measure the concentration first.
They’ll ask next
Halving cost means switching a metric to a sketch. Whose decision is that?
Hash vs sort aggregationExternal merge sort & spillingCardinality estimation
Cost hash aggregation against sort aggregation properly. When does the asymptotically worse one win?
Why they ask this
It is the concrete instance of constants beating exponents, and the memory term is what makes the answer depend on cardinality rather than on volume.
Say this
Hash is one pass and `O(g)` memory in the number of groups; sort is `O(n log n)` and `O(1)` memory. Sort wins when the groups do not fit, when the input is already ordered, or when the output must be ordered anyway.
The reasoning
Write both costs with the memory term visible, because that is the one that decides it. Hash: one pass over `n` rows, a table holding `g` entries. Sort: `n log n` comparisons and constant running state. The measured run shows the difference starkly — 500,000 rows over 183,206 groups gives a hash table of 183,206 entries against a sort whose running state is one group.
So the crossover is not about `n` at all. It is about whether `g` fits. Low cardinality means hash wins comfortably; as `g` approaches `n` the hash table approaches the size of the input, and grouping by a near-unique key is the worst case for hashing and the case where sorting is both cheaper and possible.
Then the discontinuity: when the table does not fit, hash aggregation does not degrade gracefully, it spills — partitioning to disk and reprocessing, which is a full write and read plus the risk of a partition that is still too large. That is why a plan that was fine at last year's cardinality falls off a cliff rather than sloping, and why the cost model has to include the spill branch rather than just the two clean cases.
The three cases where sort wins outright are worth having ready: the groups do not fit; the input is already sorted on the key, so the sort is free and the sweep needs no memory; and the query has an `ORDER BY` on the same key, so the sort has to happen regardless and doing it first makes the aggregation free. That last one is a genuine plan-level optimisation rather than a fallback.
See it run on CPython 3.12
Both aggregations run and are asserted to agree; only the cost differs.
The answer most people give
"Hash is O(n) and sort is O(n log n), so hash always wins." The comparison omits memory, which is the term that decides it. At high cardinality the hash table does not fit and the comparison is against a spill, not against a clean pass.
They’ll ask next
The query has an ORDER BY on the grouping key. What does that do to the comparison?
Cardinality estimationJoin algorithmsHash vs sort aggregation
An optimizer estimates ten thousand rows and gets ten million. Trace what that costs, and why the error compounds.
Why they ask this
It connects cardinality estimation to concrete plan failures, and the compounding is what explains why deep queries fail in ways simple ones do not.
Say this
Every downstream decision is made for the wrong size: a broadcast that should have been a shuffle, a hash table sized for a thousandth of what arrives, too little memory reserved. And each join's output estimate feeds the next, so the error multiplies.
The reasoning
The immediate consequences are all plan choices. A join whose small side is estimated at ten thousand rows gets a broadcast; at ten million it either dies collecting it or ships gigabytes to every node. A hash table sized for the estimate spills as soon as reality arrives. Memory grants and partition counts are set from the same number, so a thousand-fold underestimate leaves every one of them wrong in the same direction.
The compounding is what makes deep queries worse than shallow ones. A join's output estimate is derived from its inputs' estimates, so an error at the leaves propagates and multiplies. Three joins each estimated three times too small give a final estimate off by a factor of twenty-seven, and the plan for the last join — the most expensive one — is chosen from the worst number in the chain.
Which is why the errors are asymmetric in consequence. Overestimating is usually mildly wasteful: a shuffle where a broadcast would have done, more memory reserved than needed. Underestimating is catastrophic: a broadcast that OOMs, a spill, a partition count far too low. A good optimizer is deliberately conservative for that reason, and so should you be when hinting.
The fixes in order: refresh statistics, since stale stats on a growing table are the most common root cause; add multi-column statistics where supported, which directly addresses the independence assumption behind correlated predicates; materialise a problem intermediate so the next stage plans against a known size; and use adaptive execution where the engine offers it, since re-planning once real row counts are known is the only approach that fixes the compounding rather than the leaf.
The answer most people give
"It just picks a slower plan." Sometimes. It also picks plans that fail outright — a broadcast of ten million rows is an out-of-memory error in the coordinator rather than a slow query.
They’ll ask next
Why is underestimating so much worse than overestimating?
Work partitioning & skewHash vs sort aggregationTop-k / heavy hitters
Quantify the cost of skew. If one key is 30% of the rows across 200 partitions, what is the slowdown, and what does salting recover?
Why they ask this
It converts skew from a qualitative complaint into a number, and the number — the ratio of largest partition to mean — is exactly what makes the case for fixing it.
Say this
The stage takes as long as its largest partition, so the slowdown is largest-over-mean. Measured here: 60.7x before, 3.5x after splitting the hot key sixty-four ways.
The reasoning
The cost model is simple and is the thing to state first: a stage completes when its slowest task does, so the runtime is set by the largest partition rather than by the total volume. That makes the meaningful metric `largest / mean`, and it is directly measurable from task input sizes.
The measured run makes it concrete. One million rows, two hundred partitions, one key holding 30% of them: the largest partition is 303,513 rows against a mean of 5,000 — a ratio of 60.7. The cluster is effectively running at a sixtieth of its capacity for that stage, and adding nodes changes nothing because the hot partition cannot be split.
After splitting the hot key sixty-four ways with a round-robin salt, the largest partition is 17,574 against the same mean — 3.5x. Not perfect, and a seventeen-fold improvement in the stage's runtime. The residual is because sixty-four sub-keys over two hundred partitions still collide, and more salts would narrow it further at the cost of a larger combine step.
The constraint that decides whether salting is available at all: it requires a two-stage aggregation, partial results per salt then combined. Sums, counts, mins and maxes decompose that way. A median or an exact distinct count does not — you cannot combine per-salt medians — so for those the answer is a different algorithm, a sketch, or handling the hot key on a separate path entirely. Naming that limit is what distinguishes knowing the technique from knowing when it applies.
See it run on CPython 3.12
One million rows where a single key is 30% of them, seeded by content hash.
The answer most people give
"It makes the job somewhat slower." It makes it 60x slower in this case, and it is measurable in one query. Quantifying it is what turns a complaint into a prioritised piece of work.
They’ll ask next
The aggregate is a median. Why can you not salt it?
A full rebuild takes an hour; the incremental version takes a minute but can drift. How do you decide, and how do you get both?
Why they ask this
It is the same correctness-against-cost trade as the sketches, in a form every data engineer meets weekly, and the 'get both' half is the mature answer.
Say this
Incremental for the daily cost, full rebuild on a cadence as the correctness backstop. The full rebuild is not a fallback you hope not to need — it is the mechanism that makes the incremental one trustworthy.
The reasoning
The trade: a full rebuild is correct by construction, since it is a pure function of its inputs and cannot drift. An incremental update is cheap and accumulates the errors its filter cannot see — late arrivals outside the window, corrections to old partitions, deletes that no timestamp reveals, a partial write that was retried.
So the answer is not to choose. Run the incremental path for daily freshness and cost, and run the full rebuild on a cadence — weekly is usually enough — as the thing that resets accumulated drift. The rebuild is the correctness mechanism, not a contingency, and budgeting for it is part of choosing incremental in the first place.
Where a full rebuild is genuinely unaffordable, replace it with reconciliation: compare per-partition summaries between the incremental output and a recomputation over a bounded recent window, and repair what disagrees. That gets you detection at a fraction of the cost, and it works even when the raw data for older periods has aged out.
The precondition for all of it is that the rebuild is *possible*: raw inputs retained long enough, and the transformation deterministic and idempotent so the rebuild produces the same answer. A pipeline that cannot be rebuilt has no correctness backstop at all, and its incremental output is the only copy of a number nobody can re-derive — which is the state to avoid rather than a cost to optimise.
The answer most people give
"Incremental, since the numbers have been fine so far." Drift is undetectable without a comparison, so 'fine so far' means nobody has checked. The rebuild or the reconciliation is what turns that into evidence.
They’ll ask next
The raw inputs are only retained for thirty days. What does that do to your backstop?
Given a requirement, how do you choose between a hash set, a Bloom filter, HyperLogLog, Count-Min Sketch and a heap?
Why they ask this
The closing question of the subject. Each structure answers a different question and being wrong about which is the most consequential error in this area.
Say this
Match the structure to the question, not to the memory budget. Membership, cardinality, frequency and top-k are four different questions, and each has one right structure plus an exact version.
The reasoning
**Membership** — 'have I seen this key?' A hash set is exact and grows with distinct keys. A Bloom filter is fixed-size, has no false negatives and some false positives, so it is right when a false positive is merely wasted work and fatal when it would cause a row to be dropped. That direction is the single most important thing to get right.
**Cardinality** — 'how many distinct?' A set is exact and expensive; HyperLogLog is fixed-size, mergeable and about 1% wrong. Use the sketch unless the count is an obligation.
**Frequency** — 'how many times this key?' A counter map is exact and grows with distinct keys; Count-Min is fixed-size, never underestimates, and is accurate for the head and meaningless for the tail.
**Top-k** — 'which are the biggest?' A heap of size k over exact counts when the counts fit, and Count-Min or Space-Saving over the counts when they do not.
The decision procedure I would state: name the question first, check whether the exact structure fits — it usually does, and reaching for a sketch when a hash map would have worked is over-engineering — and only then choose the approximation whose error direction is harmless for your use. Then write down what the error means for the number being served, because the structure choice is a contract with whoever reads the output and nobody but you will know it was made.
The answer most people give
"Use whichever fits in memory." A Bloom filter fits and cannot count; HyperLogLog fits and cannot tell you about a specific key. Fitting is a constraint, not a selection criterion.
They’ll ask next
You need to know which keys appeared exactly once. Which of these can help, and which will mislead you?
EvergreenRetry, backoff & circuit breakingStreaming windows & watermark semantics
Implement a rate limiter allowing 100 requests per minute per key. Compare the fixed window, the sliding window log and the sliding window counter.
Why they ask this
Three correct-looking designs with three different memory profiles and three different failure modes — it is a compact test of whether someone can compare rather than just implement.
Say this
Fixed window is O(1) memory and allows a 2× burst at the boundary. Sliding window log is exact and costs memory proportional to the requests in the window. Sliding window counter approximates the log in O(1) and is what most production limiters use.
The reasoning
**Fixed window.** One counter per key per minute, reset on the boundary. O(1) memory, trivially correct to implement, and it has a real flaw: 100 requests at 10:00:59 and 100 more at 10:01:00 both pass, so a client can send 200 requests in one second. The limit says 100 per minute and the system tolerates 200 in two adjacent instants.
**Sliding window log.** Keep the timestamp of every request in the window; on each arrival, evict anything older than 60 seconds and count what remains. **Exact** — no boundary artifact at all. The cost is memory proportional to the allowed rate per key, so a 100/minute limit across a million keys is up to a hundred million timestamps, which is why this one rarely survives contact with scale.
**Sliding window counter.** Keep the current window's count and the previous window's, and estimate as `current + previous × (fraction of the previous window still in range)`. O(1) memory, no hard boundary artifact, and it is an approximation — it assumes the previous window's requests were spread evenly, so it can be slightly wrong in either direction on very bursty traffic. This is the trade almost every production limiter takes, and it is worth naming as an approximation rather than presenting it as exact.
**Token bucket is the fourth answer** and it is often the better one: a bucket refilled at a steady rate, each request taking a token. O(1) memory, and it allows a *controlled* burst up to the bucket size, which is usually what you actually want — a client should be able to send ten requests at once after being idle. If the requirement is "smooth the rate but tolerate short bursts", token bucket expresses that directly where the window designs only approximate it.
**What decides it in practice:** whether the limit must be exact (log), whether memory per key is constrained (counter or bucket), and whether a burst after idle time is desirable (bucket) or forbidden (log). And in a distributed setting all four need shared state, at which point the per-node approximation error usually dominates the choice of algorithm.
The formulations
Sliding window countership
est = curr + prev * (1 - elapsed_in_window / window)
allow if est < limit
O(1), no boundary spike, approximate. The usual production answer.
O(1), and it expresses "steady rate, controlled burst" directly.
Sliding window logworks
evict ts < now - 60; allow if len(log) < limit; log.append(now)
Exact, and memory grows with the rate times the number of keys.
Fixed windowavoid
counts[key, minute] += 1; allow if count <= limit
Allows 2× the limit across a boundary. Simplest and the leakiest.
The answer most people give
"Fixed window — a counter per minute is simplest." Simplest and it permits double the stated limit in a two-second span at every boundary, which is precisely the burst a rate limiter exists to prevent. If the limit is a safety property, this design does not provide it.
They’ll ask next
You have 8 API nodes and the limit is global. What breaks in all four designs?
EvergreenHyperLogLogStreaming windows & watermark semanticsCount-Min Sketch
You already use HyperLogLog for all-time distinct users. Now you need distinct users in the last 30 days, rolling daily. Why can you not just subtract?
Why they ask this
It probes a real limit of a tool most candidates can name — HLL merges but does not subtract — and the workaround is the interesting part.
Say this
HLL sketches union but cannot be subtracted, because a register records a maximum and removing an element cannot restore what it was before. Keep per-day sketches and merge the 30 you need at query time.
The reasoning
**Why subtraction is impossible.** An HLL register stores the maximum leading-zero count seen for the hashes landing in that bucket. Union is easy — take the elementwise maximum of two sketches, which is why HLLs merge so cleanly. Subtraction is not the inverse of a maximum: if you remove the element that set a register to 7, the correct new value is whatever the second-highest was, and the sketch never recorded it. The information is gone by construction.
**So you do not subtract, you re-merge.** Build one sketch per day. A 30-day rolling count is the union of the last 30 daily sketches, computed at query time. Merging 30 sketches is cheap — elementwise max over a fixed-size array — and it is exact with respect to the union, with only the usual HLL estimation error on the result.
**The costs are honest and small.** Storage is one sketch per day per dimension you slice by, which is a few kilobytes each. Query cost is 30 merges instead of one lookup. In exchange any window is available — 7 days, 30 days, this month against last — from the same daily sketches, with no precomputation per window. That flexibility is usually worth more than the merge cost.
**Where it stops working:** sub-daily granularity multiplies the sketch count, so hourly windows over a year is 8,760 sketches per dimension. And if the window has to slide continuously rather than by whole days, the daily grain is too coarse and you need finer sketches or a different structure — a sliding HLL with per-bucket timestamps, which exists and is considerably more complex.
**The general lesson worth stating:** sketches are mergeable, not invertible. That is true of Count-Min Sketch too, and it is the property that decides how you have to structure the precomputation — you build at the finest grain you will ever need and merge upward, never the reverse.
The formulations
Per-day sketches, merged at query timeship
SELECT HLL_COUNT.MERGE(sketch)
FROM daily_user_sketch
WHERE day BETWEEN CURRENT_DATE - 29 AND CURRENT_DATE
Any window from the same sketches. A few KB per day.
Exact distinct on raw eventsworks
SELECT COUNT(DISTINCT user_id) FROM events
WHERE day >= CURRENT_DATE - 29
Exact, and it scans 30 days of raw events every time.
Subtract the expiring dayavoid
rolling = merge(rolling, today) - day_30_ago -- not a thing
HLL has no subtraction. The register maximum is not invertible.
The answer most people give
"Keep a running sketch and remove the day that falls out of the window." There is no remove operation, and no implementation offers one — not because nobody has built it but because the sketch does not retain the information a removal would need.
They’ll ask next
You need the same rolling count sliced by country and device. What does that do to your storage?