Numbers on the back of an envelope: rows per day, bytes per row, what that costs to store, scan and reprocess, and which part of the design falls over first at ten times the volume.
Rows per day times bytes per row, and everything that turns that into a number you can defend — encoding, compression, retention and the ratios you should know.
File layout & compaction
5
The same bytes laid out two ways, one of which costs ten times as much to read. Partition granularity, file size, and the metadata nobody budgets for.
What it costs to run
5
Scan cost against storage cost, incremental against full refresh, and the two lines on the bill people forget: backfills and continuous integration.
What falls over at ten times
5
Not the warehouse, usually. Which component saturates first, and what a second region or a tokenisation vault adds to the picture.
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 — because the two fail separately, and a candidate who can debug a rerun can still stall on “what does at-least-once mean”.
01 / 22
Storage formatsFile layout & compaction
40M clickstream events a day landing as JSON in S3, kept for a year in Parquet. How much storage is that, and what do you need to know before you can answer?
The code — predict the output before reading on
ROWS_PER_DAY = 40_000_000
BYTES_PER_ROW_RAW = 420 # JSON, as the producer sends it
COMPRESSION = 0.14 # parquet + zstd, measured on a sample
RETENTION_DAYS = 365
raw_gb = ROWS_PER_DAY * BYTES_PER_ROW_RAW / 1024**3
stored_gb = raw_gb * COMPRESSION
year_tb = stored_gb * RETENTION_DAYS / 1024
print(f"raw per day {raw_gb:8.1f} GB")
print(f"stored per day {stored_gb:8.1f} GB (x{COMPRESSION} after encoding)")
print(f"one year at rest {year_tb:8.2f} TB")
print(f"rows in a year {ROWS_PER_DAY * RETENTION_DAYS / 1e9:8.1f} billion")
Why they ask this
It is the base estimation skill and it is asked constantly. What the interviewer watches for is whether you state your assumptions out loud rather than producing a number from nowhere.
Say this
You need bytes per row and the compression you will actually get after encoding. Forty million rows at a few hundred raw bytes each is a couple of gigabytes a day stored, and a few terabytes over a year.
The reasoning
The chain is short and each link is an assumption worth saying aloud: rows per day, bytes per row in the form the producer sends, the ratio you get after columnar encoding and compression, and the retention period. Give those four and the answer follows; give a number without them and an interviewer cannot tell whether you reasoned or guessed.
Bytes per row is the one people get most wrong, because JSON is far larger than it feels — repeated field names on every record, numbers as text, and no type information. The honest way to get it is to take a sample of a thousand real records and divide, and saying that you would do exactly that is a better answer than any figure you could recall.
Compression is worth carrying a rough expectation for, since it moves the result by close to an order of magnitude. Typical clickstream in Parquet with a modern codec lands somewhere around a seventh to a tenth of its raw JSON size, driven by dictionary encoding on the repetitive low-cardinality columns that dominate this kind of data. Be explicit that it is an estimate to be measured, not a constant — high-cardinality identifiers compress far worse than status codes.
What it actually prints run on CPython 3.12
Change any constant and the whole chain moves. That is the point of writing it this way.
Prints
raw per day 15.6 GB
stored per day 2.2 GB (x0.14 after encoding)
one year at rest 0.78 TB
rows in a year 14.6 billion
The answer most people give
"About a terabyte, probably." A number with no assumptions attached cannot be checked, corrected or reused — and the interviewer cannot tell whether you know the method or remembered a figure from a different system.
They’ll ask next
The product team wants to add a nested events array to every record. Redo the estimate and tell me what you would need from them.
The same 40M rows as gzipped JSON, gzipped CSV, or Parquet with zstd. A report selects 3 of the 24 columns. Which format is smallest at rest, and which is cheapest to query?
The code — predict the output before reading on
ROWS = 40_000_000
JSON_BYTES_PER_ROW = 420
CSV_BYTES_PER_ROW = 180
PARQUET_BYTES_PER_ROW = 59 # columnar + dictionary + zstd, measured
COLUMNS_TOTAL = 24
COLUMNS_READ = 3 # what the report actually selects
for name, per_row, columnar in [
("json.gz", JSON_BYTES_PER_ROW * 0.22, False),
("csv.gz", CSV_BYTES_PER_ROW * 0.30, False),
("parquet+zstd", PARQUET_BYTES_PER_ROW, True),
]:
at_rest = ROWS * per_row / 1024**3
scanned = at_rest * (COLUMNS_READ / COLUMNS_TOTAL if columnar else 1.0)
print(f"{name:<14} {at_rest:6.1f} GB at rest {scanned:6.2f} GB scanned by the report")
Why they ask this
Most candidates answer 'Parquet' to both and are half right. The interviewer wants to hear that the at-rest and the scanned figures are different questions.
Say this
Compressed CSV can be smaller at rest than Parquet — but the report reads three columns out of twenty-four, and only the columnar format lets it read just those. The saving is in the projection, not the encoding.
The reasoning
At rest the three are closer than the usual advice suggests. Gzipped CSV strips the field names that make JSON so heavy, and general-purpose compression on the result is very effective. Parquet's own encoding is better per column, but it carries footers, row-group metadata and page headers, and on a narrow table it can land slightly larger than a well-compressed CSV. The run below shows exactly that ordering.
The scanned figure is where it stops being close. A row-oriented file has to be decompressed and read in full to get at three columns, because the columns are interleaved with everything else. Parquet stores each column contiguously, so a query touching three of twenty-four reads roughly an eighth of the file — and on a warehouse billed per byte scanned, that ratio *is* the bill.
Two more properties are worth naming because they decide real designs. Parquet is splittable, so many workers read one file in parallel; a gzipped CSV is not, and one large one is processed by exactly one worker no matter how big your cluster is. And Parquet carries typed statistics per row group, so a predicate can skip whole chunks without reading them, which stacks on top of the projection saving.
What it actually prints run on CPython 3.12
Three formats, one report reading three columns of twenty-four.
The answer most people give
"Parquet is smaller, so use Parquet." It is not always smaller at rest, and leading with size misses the reason it wins — projection and predicate pushdown, which is what changes the number you are billed for.
They’ll ask next
When would you deliberately keep the raw gzipped JSON as well, knowing it costs you storage twice?
Compliance requires 3 years of raw events at 2.25 GB a day. How much of that has to sit on S3 Standard, and what does moving the rest to Glacier Instant Retrieval buy you?
The code — predict the output before reading on
GB_PER_DAY = 2.25
PRICE_HOT = 0.023 # per GB-month
PRICE_COLD = 0.004 # per GB-month, higher retrieval latency
HOT_DAYS = 90
TOTAL_DAYS = 1095 # three years
hot_gb = GB_PER_DAY * HOT_DAYS
cold_gb = GB_PER_DAY * (TOTAL_DAYS - HOT_DAYS)
flat = (hot_gb + cold_gb) * PRICE_HOT
tiered = hot_gb * PRICE_HOT + cold_gb * PRICE_COLD
print(f"hot {hot_gb:8.0f} GB cold {cold_gb:8.0f} GB")
print(f"all hot {flat:9.2f} per month")
print(f"tiered at 90d {tiered:9.2f} per month")
print(f"saved {flat - tiered:9.2f} per month ({(1 - tiered / flat) * 100:.0f}%)")
Why they ask this
Retention is usually treated as a compliance input with one answer. Splitting it into access tiers is a straightforward saving that many teams have never priced.
Say this
Almost all queries hit recent data, so keep a few months on the fast tier and push the rest to cold storage. Keeping three years hot when ninety days is what people query costs several times more for no benefit.
The reasoning
The observation the tiering rests on is that query access is heavily skewed toward recent partitions — usually well over ninety percent of reads land inside the last quarter. The older data is retained because somebody must be able to get it, not because anyone reads it weekly, and those are different requirements with very different prices attached.
Cold and archive tiers cost a fraction of standard storage and charge you in retrieval latency and in per-request fees instead. That trade is excellent for data whose realistic access pattern is an audit request twice a year, and unacceptable for anything a dashboard touches. The run below prices the same three years both ways and the gap is most of the bill.
Two cautions to raise unprompted, because they are where naive tiering goes wrong. Retrieval is not free — pulling a large archived range back can cost more than the months of storage you saved, so tier by realistic access rather than by age alone. And retention is a ceiling as well as a floor: data held past its lawful basis is a liability, so the policy should delete as well as demote.
What it actually prints run on CPython 3.12
Three years of the same data, all on the fast tier or split at ninety days.
The answer most people give
"Storage is cheap, keep everything hot." Cheap per gigabyte and not cheap in aggregate at multi-terabyte scale — and 'keep everything' quietly means keeping personal data past the point you have any basis to hold it.
They’ll ask next
Legal says three years, the product team wants seven for a model. How do you resolve that, and what does it change in the estimate?
You are asked to size Snowflake storage and a Kafka topic for a mobile event stream that does not exist yet. Nobody can tell you rows per day. How do you produce a number you can defend in a budget meeting?
Why they ask this
Real estimation happens before the data does. The interviewer wants to see you bound the answer and state the sensitivity rather than refuse or guess.
Say this
Derive the volume from something the business already counts — orders, users, sessions — then bound bytes per row with a low and high case and show which assumption the answer is actually sensitive to.
The reasoning
Anchor on a quantity somebody already knows. Nobody can tell you how many rows a new event stream will produce, but they can tell you how many orders a day, or how many active users, and events per user per session is a much easier thing to reason about than events per day in the abstract. Build the estimate as a chain of multiplications from a number the business already reports.
Then give a range rather than a point. A low case and a high case, roughly an order of magnitude apart on the uncertain terms, tells the reader what they are actually committing to — and the gap between them is where the conversation belongs. A single number implies a precision you do not have and will be quoted back to you.
The most valuable part is the sensitivity. Change each assumption in turn and see which one moves the answer most; in almost every storage estimate it is bytes per row and the compression ratio, not the row count, because the row count is the term people already have a feel for. Saying 'this is within a factor of two unless bytes per row is wildly off, and here is how we would measure that in a day' is a far stronger answer than any figure.
The formulations
Chain from a business quantityship
orders/day x events/order x bytes/event
x compression x retention
Anchors on something the business already counts, so every term can be challenged individually.
Shows what the commitment actually is and points the follow-up at the assumption that matters.
A single point estimateavoid
-- the whole estimate
"about 3 TB a year"
Implies precision you do not have, and gets quoted in a budget as though it were measured.
The answer most people give
"I would need to see the data first." Sometimes true and rarely acceptable as an answer — the decision is being made now, and an estimate with stated assumptions and a range is exactly what is being asked for.
They’ll ask next
The first week of real data arrives and bytes per row is triple your high case. What do you do?
Your fct_orders table grows linearly with orders, but dim_customer has gone from 2M rows to 80M in a year and nobody added customers. What happened?
Why they ask this
It catches a genuine surprise in dimensional platforms — that some tables grow with the *product* of things rather than with volume — and it is where storage estimates go badly wrong.
Say this
Anything whose grain is a combination: bridge tables, Type 2 dimensions with volatile attributes, and snapshot facts. Those grow with the product of cardinalities or with time regardless of activity.
The reasoning
A transaction fact grows with events, which is the term everyone estimates. Three other shapes do not. A periodic snapshot fact writes a row per entity per period whether or not anything happened — a daily balance snapshot of ten million accounts is ten million rows a day forever, and it grows when the business does nothing at all.
A Type 2 dimension grows with *change*, not with size. A customer dimension of two million rows is trivial; the same dimension tracking history on an attribute that churns weekly is two million times fifty-two per year, and the usual cause is someone adding a volatile column such as a score or a segment to a dimension that was designed around stable attributes.
Bridge tables grow with the product of the two sides, which is the one that genuinely surprises people: a many-to-many between accounts and products is not the sum of the two but their realistic combination count. The practical takeaway for an estimate is to size each table by its own grain, and to be specific that adding a fast-changing attribute to a Type 2 dimension is a storage decision as much as a modelling one.
The formulations
Transaction factship
one row per event -> grows with activity
The linear case everyone estimates correctly. Size it from events per day and stop there.
Periodic snapshot factworks
one row per entity per period
10M accounts x 365 days = 3.65B rows/yr
Grows with time and entity count even when the business does nothing. Estimate it separately.
Type 2 dimension on a volatile attributeavoid
one row per change; a weekly-churning column
-> 52 versions per entity per year
Turns a small dimension into a large fact-sized table. Adding that column is a storage decision.
The answer most people give
"Everything scales with event volume." Snapshot facts scale with time and entity count, Type 2 dimensions scale with churn, and bridges scale with a product — none of which appear in an estimate built only from events per day.
They’ll ask next
Someone wants to add a machine-learning score, refreshed nightly, to your customer dimension. What do you tell them?
A Structured Streaming job with 60 shuffle partitions writes to a Delta table every 10 minutes into one daily partition. What does that directory look like after a day, and why does the read side care?
The code — predict the output before reading on
GB_PER_DAY = 2.25
TARGET_FILE_MB = 256
WRITERS = 60 # parallel tasks, each writing its own file
FLUSHES_PER_WRITER = 144 # one every 10 minutes
mb_per_day = GB_PER_DAY * 1024
naive_files = WRITERS * FLUSHES_PER_WRITER
naive_mb = mb_per_day / naive_files
compacted_files = max(1, round(mb_per_day / TARGET_FILE_MB))
print(f"as written {naive_files:6d} files/day at {naive_mb:6.2f} MB each")
print(f"compacted {compacted_files:6d} files/day at {mb_per_day / compacted_files:6.1f} MB each")
print(f"file ratio {naive_files / compacted_files:6.0f}x")
print(f"over a year {naive_files * 365:,} files vs {compacted_files * 365:,}")
Why they ask this
Everyone has heard of the small files problem. The interviewer wants the arithmetic — how many files that actually is — because the number is what makes the argument.
Say this
Writers times flushes gives thousands of tiny files a day where a handful of properly sized ones would do. Each one is a separate open, a separate footer read and a separate task, so the fixed cost per file dominates the actual data.
The reasoning
The arithmetic is just multiplication and the result is startling, which is why it is worth doing out loud: sixty writers flushing every ten minutes produce thousands of files a day, each a tiny fraction of a megabyte, against a handful at a sensible target size. Over a year that is millions of objects for a few terabytes of data.
The read cost is per file and largely independent of file size. Every file means a listing entry, an open, a footer read, and typically a task scheduled to process it. When the file holds a quarter of a megabyte, the overhead is many times the work — so a query over that partition spends its time on file handling rather than on data, and adding cluster capacity barely helps because the bottleneck is per-object, not per-byte.
There are two other costs worth naming. Object stores rate-limit listings, so a directory with hundreds of thousands of entries becomes slow to enumerate before anything is read. And table formats keep metadata per file, so the manifest itself grows into something that has to be read before planning can start. The fix is compaction — a scheduled rewrite into target-sized files — and the target is typically in the hundreds of megabytes, chosen to match the engine's split size.
What it actually prints run on CPython 3.12
The same bytes, written as they arrive and compacted to a target size.
The answer most people give
"Add more workers so it reads faster." The bottleneck is per-file overhead, so more workers means more concurrent tiny reads and more listing pressure — you pay for more compute to do the same amount of file handling.
They’ll ask next
When does the compaction job run, and what does it cost you to run it every night?
A 2-year Delta table could be PARTITIONED BY (day), by (day, hour), or by (day, country) with 40 countries. What does each choice cost you before a single query runs?
The code — predict the output before reading on
DAYS = 730
FILES_PER_PARTITION = 4
BYTES_PER_MANIFEST_ENTRY = 1_200
for name, partitions in [("daily", DAYS), ("hourly", DAYS * 24), ("by day + country (40)", DAYS * 40)]:
files = partitions * FILES_PER_PARTITION
metadata_mb = files * BYTES_PER_MANIFEST_ENTRY / 1024**2
print(f"{name:<22} {partitions:>7,} partitions {files:>8,} files {metadata_mb:7.1f} MB of manifest")
Why they ask this
Over-partitioning is one of the most common self-inflicted performance problems, and it is invisible until the table is large. The count is the argument.
Say this
Partition count multiplies, and every partition carries files and metadata. Two years daily is manageable; hourly is twenty-four times that; day plus a forty-value column is worse again — all before anyone benefits from the pruning.
The reasoning
Partitioning exists to let a query skip data, and it pays for itself only when queries actually filter on the partition column. The cost is paid regardless: each partition is a directory or a metadata group, holds at least one file, and contributes entries the planner has to read before it can decide what to scan.
The multiplication is what catches people. Going from daily to hourly does not add complexity, it multiplies partition count by twenty-four — and adding a second column multiplies again by its cardinality. The run below shows two years under three schemes, and the manifest alone goes from a few megabytes to over a hundred, which is read on every query plan.
The rule of thumb worth stating is to size partitions so each holds enough data to be worth a scan — commonly at least a gigabyte, certainly not megabytes — and to partition only on columns queries genuinely filter by. If most queries hit a range of days, day is right and hour is waste. For the second dimension, prefer clustering or sort order inside the partition, which gives you skipping without multiplying the partition count.
What it actually prints run on CPython 3.12
Two years of the same table under three partitioning schemes.
The answer most people give
"Partition on everything queries filter by, so nothing scans more than it needs." Each column multiplies the partition count, and past a certain point the planner spends longer reading metadata than the scan would have taken.
They’ll ask next
Queries filter on day and on country. Day is the partition — what do you do about country?
A Looker dashboard runs a 30-day query against a 2-year, 800 GB table, 400 times a day on BigQuery. Price it with and without partition pruning.
The code — predict the output before reading on
TB_TOTAL = 0.80 # two years of this table, at rest
DAYS_TOTAL = 730
DAYS_QUERIED = 30
PRICE_PER_TB_SCANNED = 5.00
QUERIES_PER_DAY = 400
full = TB_TOTAL
pruned = TB_TOTAL * DAYS_QUERIED / DAYS_TOTAL
for name, tb in [("no partition pruning", full), ("pruned to 30 days", pruned)]:
per_query = tb * PRICE_PER_TB_SCANNED
print(f"{name:<22} {tb:6.4f} TB/query {per_query:7.4f} per query "
f"{per_query * QUERIES_PER_DAY * 30:9.2f} per month")
Why they ask this
It converts a layout decision into a monthly figure, which is how a platform engineer makes the case to someone who does not care about file formats.
Say this
Pruning turns a full-table scan into a thirtieth of one, and on a per-byte-scanned engine that ratio lands straight on the bill. Four hundred queries a day makes the difference the difference between a rounding error and a line item.
The reasoning
The mechanism is simple: if the table is partitioned by day and the query filters on day, the engine reads only those partitions. Without partitioning — or with a filter the engine cannot map to partitions — it reads everything and discards most of it after the fact. Same result, two very different quantities of bytes.
Multiplying by query volume is what makes the point land. A single query's difference looks trivial; four hundred a day over a month is the figure in the run below, and it is the kind of number that gets a layout change prioritised. This is worth doing in an interview because it shows you can translate a technical decision into the terms the person approving it uses.
Two ways pruning silently stops working are worth naming. A function applied to the partition column in the predicate — casting or formatting the date — can prevent the engine from matching it, so the filter is applied after a full scan. And a join whose filter lives on the other table may not propagate, depending on the engine, so the fact table is scanned whole. Reading the query plan is how you find out, and 'I would check the plan' is a better answer than assuming.
What it actually prints run on CPython 3.12
One dashboard query, priced two ways, at the volume it actually runs.
The answer most people give
"Storage is the expensive part of a warehouse." For most analytical platforms scan-based compute dominates by a wide margin, and layout decisions are worth far more than the storage they save.
They’ll ask next
The predicate casts the partition column to a string. What happens, and how would you have caught it?
You have a small files problem on a Delta table that a Structured Streaming job is still writing to. When does OPTIMIZE run, and what has to be true for it to be safe?
Why they ask this
Naming compaction is easy; running it against a live table is the part with real constraints. The interviewer wants to hear about readers, not just about file sizes.
Say this
After the writers for a partition are done and before the readers care — usually a scheduled pass over closed partitions. It is only safe if the table format gives readers a consistent snapshot while files are being rewritten.
The reasoning
Compaction rewrites data that queries are reading, so the first requirement is isolation. On a table format with atomic commits — Iceberg, Delta, Hudi — the rewrite produces a new snapshot and readers continue on the old one until it is committed, which makes the operation safe by construction. On plain files in a directory there is no such guarantee, and a reader can catch the directory mid-rewrite; that is the strongest practical argument for a real table format.
The scheduling question is about which partitions are finished. Compacting a partition still being written to just creates more small files behind you, so the usual pattern is a pass over partitions whose write window has closed — yesterday and older — rather than a continuous rewrite. Streaming ingestion often runs compaction on a lag for exactly this reason.
Then treat it as a real job with real cost. It reads and rewrites the data, so it is not free, and on a metered engine it appears on the bill next to everything else. It should be monitored like any other pipeline, because a compaction job that has been silently failing for a fortnight is discovered as a query performance problem rather than as a failure. **What would change my answer**: streaming ingestion with hot reads on the current partition, where the answer becomes a rolling compaction with a shorter lag and a higher cost.
The formulations
Scheduled pass over closed partitionsship
nightly: compact partitions older than the write window
into target-sized files
Simple, cheap and safe. The default whenever the write window for a partition genuinely closes.
Rolling compaction on a short lagworks
compact partitions ~1h behind the write head
For streaming ingestion with hot reads on recent data. More cost, and the only option that helps there.
Compact in place on plain filesavoid
rewrite files in the directory while readers are active
No snapshot isolation, so a reader can catch the directory mid-rewrite and see partial data.
The answer most people give
"Turn on auto-compaction and forget it." Managed compaction is genuinely useful and it is still a job that consumes compute, can fall behind, and can fail silently — so it needs the same monitoring as anything else you depend on.
They’ll ask next
Compaction has been failing for two weeks. How would you have found out before a user complained about query time?
Queries filter on event_date and on customer_id. The table is already PARTITIONED BY (event_date). Why not add customer_id to the partition spec?
Why they ask this
It is the natural next step after over-partitioning, and it tests whether a candidate knows there is a second mechanism for data skipping that does not multiply partition count.
Say this
Because customer is high cardinality, so partitioning by it multiplies partitions into the millions and produces tiny files. Sorting or clustering by customer inside each day gives skipping without any of that.
The reasoning
Partitioning is a physical directory split, so its cost scales with the number of distinct values. That is fine for a date and catastrophic for an identifier: partitioning by customer means one directory per customer per day, each holding a handful of rows, which is the small files problem produced deliberately.
Clustering — or sorting within the partition, depending on the engine's vocabulary — gets you most of the benefit without the split. The data is ordered by the clustering column, and the file or row-group statistics then let the engine skip anything whose range cannot contain the value. A query for one customer reads a few chunks of a partition rather than all of it, and there are no extra directories at all.
The rule that follows: partition on low-cardinality columns that bound the query range, typically a date; cluster on the high-cardinality columns queries filter or join by. Clustering is not free — it has to be maintained as data arrives, which is usually folded into the compaction pass — but the cost is a background job rather than a permanent multiplication of your metadata.
The formulations
Partition by day, cluster by customership
PARTITION BY day CLUSTER BY customer_id
Range-bounding from the partition, value skipping from the clustering, and no extra directories.
Partition by day onlyworks
PARTITION BY day -- no clustering key
Fine when per-customer queries are rare; the day filter already bounds the scan enough.
Partition by day and customeravoid
PARTITION BY day, customer_id -- millions of partitions
One directory per customer per day, each holding a few rows. Small files, created on purpose.
The answer most people give
"Partition by anything queries filter on." Cardinality decides it. A date has hundreds of values a year and an identifier has millions, and the second one turns your table into a metadata problem.
They’ll ask next
Clustering has to be maintained. Where does that work happen and what does it cost?
Both the Conceptual and Failure banks argue for full refresh as the baseline. This asks the candidate to say when the economics overturn that, with a number.
Say this
At one run a day the difference is often too small to justify the complexity. Multiply by twenty-four runs and it becomes a real line on the bill — frequency, not table size, is usually what forces the decision.
The reasoning
Full refresh scans the whole table every run; incremental scans a day of it. The ratio is roughly the retention length in days, which sounds decisive until you multiply by how often the job runs. Once a day, on a table of this size, the absolute difference is small enough that many teams should simply keep the refresh and keep the correctness guarantees that come with it.
Hourly changes the picture entirely, because the full-refresh cost multiplies by twenty-four while the incremental cost stays proportional to the new data. The run below shows all three frequencies, and the pattern is that **run frequency drives this decision more than table size does** — which is not most people's intuition.
Put the engineering cost on the same page before deciding. Going incremental buys you a watermark to maintain, a backfill procedure, deletes you can no longer see for free, and a reconciliation job you should now build. Against a modest monthly saving that is a poor trade; against a large one it is obvious. The honest recommendation is to compute both numbers and let the gap decide, rather than reaching for incremental because it sounds more professional.
What it actually prints run on CPython 3.12
The same table, refreshed and loaded incrementally, at three frequencies.
The answer most people give
"Incremental is obviously cheaper, so always do it." Cheaper in scan and more expensive in engineering and incidents — at one run a day the saving frequently does not cover the reconciliation job you now need.
They’ll ask next
Where is the break-even for your platform, and what would you have to know to compute it?
A currency bug means reprocessing 400 daily partitions of a Delta table, 2.2 GB each, at 7 minutes per partition. Estimate the cost, the wall clock, and the effect on everything else running that night.
The code — predict the output before reading on
DAYS_TO_BACKFILL = 400
TB_PER_PARTITION = 0.0022
PRICE_PER_TB_SCANNED = 5.00
MINUTES_PER_PARTITION = 7
CONCURRENCY = 8
NIGHTLY_BUDGET_TB = 0.10 # what the rest of the platform needs to stay inside
tb = DAYS_TO_BACKFILL * TB_PER_PARTITION
hours = DAYS_TO_BACKFILL * MINUTES_PER_PARTITION / CONCURRENCY / 60
print(f"scanned {tb:7.2f} TB")
print(f"cost {tb * PRICE_PER_TB_SCANNED:7.2f}")
print(f"wall clock {hours:7.1f} hours at concurrency {CONCURRENCY}")
print(f"vs one nightly run of everything else: {tb / NIGHTLY_BUDGET_TB:.0f}x its daily budget")
print(f"throttled to the budget it would take {tb / NIGHTLY_BUDGET_TB:.0f} nights")
Why they ask this
Backfills are approved without being estimated, and then they saturate the warehouse and break somebody else's SLA. The third part of the question is the one that matters.
Say this
Scan cost is the easy part and usually modest. The hard parts are wall clock at your concurrency limit and the fact that the backfill is many times a normal night's budget, so unthrottled it will starve everything else.
The reasoning
Take it in three pieces. Scan cost is partitions times bytes times price, and it is often surprisingly affordable — which is why people approve backfills without thinking further. Wall clock is partitions times duration divided by concurrency, and that is the number that determines whether this finishes tonight or on Thursday.
The third piece is contention, and it is the one that causes incidents. The run below compares the backfill against what the rest of the platform consumes in a night: it is several times a normal day's budget, so running it at full speed means every other pipeline queues behind it. Throttled to fit inside the spare capacity, the same work spreads across several nights — which is usually the right answer and needs to be said before the work starts, not after someone else's dashboard is late.
Two practical requirements follow. It must be resumable: four hundred partitions processed independently with progress recorded means a failure at partition three hundred resumes rather than restarts. And it should be ordered by value — most recent partitions first, because those are what people are actually looking at, so the useful part of the backfill lands on the first night even if the tail takes a week.
What it actually prints run on CPython 3.12
Four hundred partitions, at the concurrency the platform can spare.
The answer most people give
"It is a one-off, just run it." A one-off that consumes the warehouse for six hours breaks the SLA of every pipeline sharing it, and the people affected find out from their own stakeholders rather than from you.
They’ll ask next
It dies at partition three hundred. What happens next, and what did you build earlier to make that answer short?
Data contracts & quality gatesOrchestration & dependencies
240 dbt models, 14 pull requests a day, and CI runs dbt build with no selector on every one. What is that costing, and what would you change?
The code — predict the output before reading on
MODELS = 240
TB_PER_FULL_BUILD = 0.42
PRICE_PER_TB_SCANNED = 5.00
PRS_PER_DAY = 14
WORKING_DAYS = 21
full_ci = TB_PER_FULL_BUILD * PRICE_PER_TB_SCANNED * PRS_PER_DAY * WORKING_DAYS
CHANGED_FRACTION = 0.06 # a PR touches ~6% of the graph, with its children
slim_ci = full_ci * CHANGED_FRACTION
SAMPLE_FRACTION = 0.01 # dev builds against 1% sampled sources
sampled = slim_ci * SAMPLE_FRACTION
print(f"{MODELS} models, {PRS_PER_DAY} PRs/day")
print(f"full build every PR {full_ci:9.2f} per month")
print(f"modified + children only {slim_ci:9.2f} per month")
print(f"and sampled sources {sampled:9.2f} per month")
Why they ask this
Continuous integration cost is invisible because it is nobody's feature. It is a genuine line item at scale and a good test of whether a candidate thinks about the whole platform.
Say this
A full build on every pull request scans the entire graph fourteen times a day for changes that touch a fraction of it. Building only what changed and its children, against sampled sources, removes almost all of it.
The reasoning
The waste is structural: a pull request touching one model triggers a rebuild of two hundred and thirty-nine that did not change. Multiplied by pull requests per day and working days per month it becomes the figure in the run below, spent entirely on recomputing unchanged results.
Two changes remove most of it. Build only the modified models and their downstream children — every serious transformation tool supports selecting a subgraph, and the typical pull request touches a small percentage of the graph. Then run development and continuous integration against sampled or limited sources rather than full history, since the purpose is to prove the SQL compiles and the tests pass, not to produce a correct production number.
Two cautions so this does not become false economy. Sampling can hide bugs that only appear at volume or on rare values, so a full build on the main branch — nightly, or before release — is still worth running. And state-based selection depends on an accurate manifest of what production currently is; when that drifts, the tool silently builds the wrong subgraph, which is worse than building everything.
What it actually prints run on CPython 3.12
The same continuous-integration policy under three selection strategies.
The answer most people give
"Development cost is noise compared with production." At a few hundred models and a busy team it is frequently comparable to a production pipeline, and it is invisible precisely because no one owns it as a feature.
They’ll ask next
What do you still run a full build for, and how often?
Finance asks you to cut the Snowflake bill by a third. Where do you look first, and what do you refuse to touch?
Why they ask this
It tests whether a candidate knows the shape of a typical bill. Attacking storage first is the common instinct and usually the wrong one.
Say this
Look at compute, because on most analytical platforms scan and query cost dominates storage by a wide margin. What you refuse to touch is retention of data you are legally required to keep, and the tests.
The reasoning
Get the split before proposing anything. On a typical warehouse platform, compute — transformations, dashboard queries, ad-hoc analysis — is the large majority of the bill and storage is a minority of it. That means a heroic effort on retention policy can move a small fraction while an afternoon spent on the three most expensive queries moves considerably more.
The reliable targets, in order: the handful of queries or models that account for most of the scan, which is nearly always a very short list; full refreshes running far more often than the data changes; dashboards auto-refreshing for nobody at 3am; and continuous integration rebuilding everything on every change. Each of those is a policy change rather than an engineering project.
Two things to defend. Retention of data with a legal or contractual basis is not a cost lever, and offering it first signals you do not know that. And the test suite is not either — cutting the checks to save scan cost trades a visible line on the bill for an invisible increase in the chance of a wrong number, which is a much more expensive kind of failure. **What would change my answer**: if the split turned out to be storage-dominated, which happens on lakehouses with very large raw retention, then tiering moves to the top of the list.
The formulations
Find the top scanning queries and modelsship
rank by bytes scanned x frequency
-> the top 10 are usually most of the bill
Nearly always a very short list, and each fix is a policy change rather than a project.
Cut refresh frequency to match how fast the data changesship
hourly -> 4x daily where the source updates daily
Pure waste removal with no loss of usable freshness. Usually the fastest large saving.
Cut the test suite to reduce scanavoid
drop nightly tests on non-critical models
Trades a visible bill for an invisible increase in wrong numbers. The wrong thing to spend first.
The answer most people give
"Reduce retention, storage is where the data is." Storage is where the *data* is and not usually where the money is — and it is the one lever with legal constraints attached, which makes it a poor place to start.
They’ll ask next
You have found the top ten queries. Two of them belong to the finance team. How do you approach that conversation?
The Snowflake bill arrives as one number and six teams caused it. Do you attribute it back to them, and what behaviour does that produce?
Why they ask this
It is a platform-ownership question that senior data engineers are genuinely asked. The interesting part is the behaviour chargeback produces, not the accounting.
Say this
Show the cost per team whether or not you bill for it — visibility alone changes behaviour. Hard chargeback also works and has a predictable side effect: people optimise for their own line rather than for the platform.
The reasoning
One undifferentiated number is nobody's problem, so nothing changes. Attributing spend by team, model or dashboard turns it into something with an owner, and in most organisations showing the number is enough — a team seeing that one dashboard is a large fraction of their consumption will usually fix it without being charged.
Hard chargeback goes further and has real effects in both directions. It creates genuine incentive to clean up, and it also creates incentives you did not intend: teams avoid shared models because the cost lands on whoever materialises them, duplicate work into their own space where it is cheaper for them and more expensive overall, and argue about attribution instead of about design. That is a predictable outcome and worth anticipating out loud.
The mechanics matter too. Attribution needs tagging by team, model and dashboard from the start — retrofitting it onto an untagged platform is painful — and shared infrastructure has to be allocated by some rule everyone accepts as roughly fair rather than exactly right. The pragmatic position is showback by default, with chargeback only where budgets genuinely sit with the consuming teams. **What would change my answer**: an organisation where teams hold real budgets and the platform does not — there, chargeback is the only mechanism that works.
The formulations
Showback: attribute and publish, do not billship
tag by team/model/dashboard
-> monthly report, no invoice
Visibility alone changes behaviour in most organisations, with none of the perverse incentives.
Chargeback where budgets sit with consuming teamsworks
tagged spend -> team budget
The only mechanism that works when the platform holds no budget. Expect avoidance of shared models.
One undifferentiated platform billavoid
single line item, no attribution
Nobody's problem, so nothing improves — and you cannot answer 'who caused the increase'.
The answer most people give
"Charge everything back, teams should own their costs." It works and it produces behaviour you did not ask for — duplicated models built to avoid shared ones, and arguments about attribution replacing arguments about design.
They’ll ask next
A shared dimension is used by every team. Who pays for it, and what rule do you write down?
Volume grows 10x over 18 months. Your nightly extract window is 4 hours against a 6-hour limit, the warehouse scans 0.05 TB a day against a 20 TB quota, and a partition holds 8,640 files. Which breaks first?
The code — predict the output before reading on
GROWTH = 10
components = [
# name, used now, ceiling, unit
("source extract window", 4.0, 6.0, "hours"),
("warehouse scan/day", 0.05, 20.0, "TB"),
("files in one partition", 8_640, 100_000, "files"),
("orchestrator tasks/day", 1_200, 30_000, "tasks"),
]
print(f"{'component':<24}{'now':>10}{'at 10x':>12}{'ceiling':>12} verdict")
for name, now, ceiling, unit in components:
at_scale = now * GROWTH
verdict = "BREAKS" if at_scale > ceiling else "holds"
print(f"{name:<24}{now:>10,.2f}{at_scale:>12,.2f}{ceiling:>12,.2f} {verdict}")
Why they ask this
The signature Cost & Scale question. Candidates reach for the warehouse; the answer is usually the thing with a hard boundary rather than an elastic one.
Say this
Whatever has a fixed ceiling rather than an elastic one — most often the extract window against the source, because the source is somebody's production database and cannot simply be scaled for you.
The reasoning
Do it as a table rather than an argument: list each component, what it uses now, what its ceiling is, and multiply. The run below does exactly that and the result is the interesting part — the warehouse scan grows tenfold and remains comfortably inside its limit, because warehouse compute is elastic and you buy more of it. The extract window does not, because it is bounded by a maintenance window on a production system you do not control.
That is the general pattern. Elastic components — warehouse compute, object storage, worker pools — degrade into higher bills rather than into failures, which is unpleasant but not an outage. Inelastic ones fail hard: a nightly window that must end before the business day, a source database that cannot serve a larger extract without affecting its own users, a single-threaded step, an API rate limit, a per-account quota.
So the useful discipline is to know which of your components is inelastic and what its actual number is, before growth arrives. And the answers when it does are usually structural rather than about capacity: switch from a full extract to a change feed so the extract stops scaling with table size, or split the extract across a wider window, or move it off the primary. Each of those is weeks of work, which is why the estimate is worth doing eighteen months early.
What it actually prints run on CPython 3.12
Each component at ten times its current load, against its own ceiling.
The answer most people give
"We would scale up the warehouse." The warehouse is the component that scales most easily, so it is rarely the binding constraint — and the answer skips the components that cannot be scaled by spending money.
They’ll ask next
The extract window is the constraint. Give me three options and the one you would pick.
Compliance wants the 2.4 TB lake recoverable in a second region. Price the storage and replication, and say what that price does not include.
The code — predict the output before reading on
GB_STORED = 2_460
PRICE_PER_GB_MONTH = 0.023
PRICE_PER_GB_EGRESS = 0.020
DAILY_CHANGE_GB = 2.25
single = GB_STORED * PRICE_PER_GB_MONTH
replica_storage = GB_STORED * PRICE_PER_GB_MONTH
replica_transfer = DAILY_CHANGE_GB * 30 * PRICE_PER_GB_EGRESS
backfill_transfer = GB_STORED * PRICE_PER_GB_EGRESS
print(f"single region {single:9.2f} per month")
print(f"+ replica storage {replica_storage:9.2f} per month")
print(f"+ ongoing replication {replica_transfer:9.2f} per month")
print(f"= two regions {single + replica_storage + replica_transfer:9.2f} per month "
f"({(single + replica_storage + replica_transfer) / single:.2f}x)")
print(f"one-off seed of the replica {backfill_transfer:9.2f}")
Why they ask this
Disaster recovery is agreed in principle and costed late. The interviewer wants both the arithmetic and the recognition that the running cost is the easy part.
Say this
Roughly double the storage plus continuous replication of the daily delta and a one-off seed. What that does not include is compute in the second region, the engineering to make failover work, or the testing that proves it does.
The reasoning
The storage arithmetic is the straightforward part and the run below covers it: a second copy of everything at rest, ongoing transfer of the daily change, and a one-off cost to seed the replica. Roughly double, plus a modest replication line — which is why the running cost alone often sounds acceptable in a meeting.
What that figure omits is most of the real cost. Compute in the second region either sits idle waiting or does not exist until you need it, and the second case means your recovery time includes provisioning. Orchestration, secrets, catalogue and connector configuration all have to exist there too. And failover has to actually be exercised — an untested disaster recovery plan is a document, and the day you need it is the worst possible day to discover which piece was never replicated.
The question that should come before the price is what recovery objectives are actually required. A recovery point of twenty-four hours and a recovery time of a week is a nightly copy to another region and very little else. A recovery point of minutes and a recovery time of an hour is continuous replication with warm compute and regular failover drills, which is a different order of cost. Those two numbers decide the design, and asking for them is the strongest move available.
What it actually prints run on CPython 3.12
Storage and replication for a second region. The engineering is not in here.
The answer most people give
"Just enable cross-region replication on the bucket." That covers the bytes and none of the platform — orchestration, catalogue, secrets and compute are all still single-region, so you can read the files and cannot run anything.
They’ll ask next
Give me the recovery point and recovery time you would design for, and justify each.
Three PII fields per row on 40M rows a day must be tokenised in flight through a vault billed per million calls. What does that do to the design, and what does a cache change?
The code — predict the output before reading on
ROWS_PER_DAY = 40_000_000
PII_FIELDS_PER_ROW = 3
VAULT_CALLS_PER_1K_TOKENS = 1.0
PRICE_PER_MILLION_CALLS = 4.00
CACHE_HIT_RATE = 0.97 # most values are seen again the same day
raw_calls = ROWS_PER_DAY * PII_FIELDS_PER_ROW
cached_calls = raw_calls * (1 - CACHE_HIT_RATE)
for name, calls in [("no cache", raw_calls), ("with a 97% cache", cached_calls)]:
monthly = calls * 30 / 1e6 * PRICE_PER_MILLION_CALLS
print(f"{name:<18} {calls:>12,.0f} calls/day {monthly:9.2f} per month")
print(f"cache saves {(raw_calls - cached_calls) * 30 / 1e6 * PRICE_PER_MILLION_CALLS:.2f} per month")
Why they ask this
It is the point where a governance requirement becomes an engineering constraint with a number attached, and where a cache stops being an optimisation and becomes the design.
Say this
Naively it is three vault calls per row, which at this volume is an enormous call count and an enormous bill. A cache on the token mapping collapses it, because the same values recur constantly within a day.
The reasoning
Start with the naive figure because it is what makes the case: rows per day times fields per row is the call count, and at tens of millions of rows that is a number no per-call service is priced for. It is also a latency problem — a synchronous call per field per row serialises the entire pipeline behind a network round trip.
The property that saves it is that personal identifiers repeat. The same customers appear many times a day, so the mapping from value to token is highly cacheable, and a high hit rate turns the call volume into a small fraction of the naive one — the run below shows the difference. Deterministic tokenisation, where the same input always yields the same token, is what makes caching possible at all, and it is also what lets you join on the token afterwards.
Two constraints a security reviewer will raise. The cache of value-to-token mappings is itself sensitive — it contains the plaintext — so it needs the vault's protection and a bounded lifetime. And deterministic tokenisation is weaker than random: identical values are visibly identical, so frequency analysis leaks information about a small domain. That is an acceptable trade for a high-cardinality identifier and a poor one for a postcode.
Tokenisation is also not the only control, and naming the other two is what makes the answer complete. **Dynamic masking** leaves the real value in the table and hides it at query time based on the caller's role — cheap, reversible, and only as strong as your access control, because the value is still there for anyone who gets past it. **Column-level access control** removes the column from the query result entirely for unauthorised roles, which is stronger and does not survive a copy of the table into someone's own schema. Tokenisation is the only one of the three that travels with the data, which is why it is what a lake uses and masking is what a warehouse adds on top.
What it actually prints run on CPython 3.12
The same tokenisation requirement, with and without a cache on the mapping.
The answer most people give
"Tokenise everything, it is only a function call." At tens of millions of rows a day a per-call service is both a cost and a latency bottleneck, and the pipeline ends up spending most of its time waiting on a network round trip.
They’ll ask next
Your cache holds plaintext by definition. How do you protect it, and how long do you keep it?
A Spark join of fct_orders to dim_customer runs in 2 minutes today and takes an hour at 10x the data. Why is the growth not linear?
Why they ask this
It tests whether a candidate understands that some operations scale worse than the data. This is the difference between an estimate that holds and one that is wrong by an order of magnitude.
Say this
Because a join is not linear once it stops fitting in memory. Below the threshold it is a hash lookup; above it, the engine spills to disk and shuffles across the network, and the cost steps rather than sloping.
The reasoning
Below a threshold, a join where one side fits in memory is broadcast to every worker and the operation is close to linear — each row does a hash lookup. Above it, the engine cannot broadcast and switches strategy: both sides are shuffled across the network by key, and any partition that does not fit spills to disk. The cost does not slope upward at that point, it steps.
Skew makes it worse and is the usual real cause. If the join key is unevenly distributed — a null placeholder, a default customer, one enormous account — then one partition receives a disproportionate share and one task runs while the others have finished. Total data grew tenfold; the largest partition may have grown far more, and the job's duration is set by that single task.
For estimation the practical lesson is that anything involving a shuffle should be assumed to scale worse than linearly, and that the interesting question is where the thresholds are: when does the small side stop being broadcastable, and what is the distribution of the join key. The mitigations are known — pre-aggregate before joining, filter earlier, salt a skewed key, or denormalise the lookup away — but they are structural changes, so knowing the threshold in advance is what gives you time to make them.
The formulations
Broadcast the small sideship
small side < broadcast threshold -> hash lookup per row
Near-linear and the fastest option, right up until the small side stops being small.
Shuffle join with a salted keyworks
salt the skewed key into N buckets, join, aggregate
The standard mitigation when one key dominates. Adds a stage and rescues the straggler task.
Shuffle join on a skewed keyavoid
join on customer_id where one account is 30% of rows
One partition gets most of the data, and the job's duration is set by a single task.
The answer most people give
"It grew ten times, so it takes ten times as long." True for scans and false for anything involving a shuffle — which is where the estimate goes wrong by an order of magnitude rather than by a bit.
They’ll ask next
How would you find out today whether your join key is skewed, before the growth arrives?
A Delta table has 400 GB of files no current version references, mostly left by nightly OPTIMIZE. Someone proposes VACUUM RETAIN 0 HOURS to reclaim it tonight. What breaks, and what is the safe way?
Why they ask this
Table maintenance is where lakehouse storage bills are won and where people delete something they needed. It is asked at every shop running Delta or Iceberg.
Say this
It destroys time travel and can break a reader mid-query, because those files are exactly what an older snapshot and an in-flight scan point at. The safe way is a retention window longer than your longest query and longer than any snapshot you rely on.
The reasoning
The tombstoned files are not garbage in the sense people assume. They are the previous versions of the table, which is what time travel reads — VERSION AS OF and RESTORE both resolve to files VACUUM would delete. Reclaiming them is fine when you have decided you no longer need to read the past, and it is a decision rather than a cleanup.
RETAIN 0 HOURS is dangerous for a second and less obvious reason: a long-running reader that resolved the file list minutes ago is still reading those paths. Delete them underneath it and the query fails with a missing file rather than returning wrong data — which is at least loud, but it fails a report nobody expected to fail. The default retention exists to be longer than any plausible in-flight query, which is why engines make you override a safety check to go below it.
The safe way is to set retention deliberately from two numbers: the longest query you run, and the furthest back anyone needs to time travel. Then run OPTIMIZE and VACUUM on a schedule, monitor them like any other job, and understand what each does — OPTIMIZE bin-packs small files into larger ones and ZORDER additionally sorts by a clustering column so file statistics can skip more; VACUUM only removes what OPTIMIZE and other rewrites left behind. Running VACUUM without OPTIMIZE reclaims very little, which surprises people.
The formulations
OPTIMIZE nightly, VACUUM on a retention windowship
OPTIMIZE events ZORDER BY (customer_id);
VACUUM events RETAIN 168 HOURS;
Compacts and clusters, then reclaims only what is unreferenced and older than any reader or snapshot.
Set retention from measured query duration and time-travel needship
longest query ~40 min; time travel needed 7 days
-> RETAIN 168 HOURS
Two numbers you can observe, rather than a default nobody chose or an override nobody justified.
VACUUM RETAIN 0 HOURS to reclaim it tonightavoid
SET ...retentionDurationCheck.enabled = false;
VACUUM events RETAIN 0 HOURS;
Destroys time travel and can pull files from under an in-flight reader. The safety check exists for both.
The answer most people give
"Those files are unreferenced, so deleting them is free." They are referenced — by every older snapshot and by any query that resolved its file list before you started. Unreferenced by the *current* version is a much weaker property than it sounds.
They’ll ask next
You run OPTIMIZE nightly and storage keeps growing anyway. What is happening?
What is compaction, why does a Merge-on-Read table in Hudi or Iceberg need it, and how do you decide when to run it?
Why they ask this
It is the maintenance job that decides whether a lakehouse table stays queryable, and it is the one part of the lakehouse story most candidates have never had to operate.
Say this
Compaction merges base files and their accumulated delta logs into new clean files, so readers stop paying a merge cost. Minor compaction folds the logs together, major compaction rewrites the base — and it runs on a trigger, never continuously.
The reasoning
**Why it is needed.** Merge-on-Read keeps writes cheap by never rewriting base files: an update becomes a delta record. Every query then has to read the base file, read every outstanding delta, resolve by key and apply deletes before it can return a row. That cost grows with the number of deltas, so read latency degrades continuously and unpredictably as writes accumulate. Compaction is what resets it.
**What it does.** Read the base and its deltas, merge by key, apply deletes, write a new optimised file, update the metadata, drop the old files. The effect is to turn Merge-on-Read data back into Copy-on-Write-shaped data — after compaction a reader touches one clean file again.
**Two kinds, and they cost differently.** *Minor* compaction merges only the delta or log files with each other, leaving base files alone. It is fast, it cuts the log count, and it is what you run frequently against a hot landing table. *Major* compaction merges deltas into the base and rewrites full data files. It is expensive and it produces the best possible read performance, so you run it when the data has stabilised or ahead of an SLA-critical read window.
**When to trigger.** Four workable signals: time-based (hourly or nightly), file-count (more than some number of delta files), size-based (delta volume approaching base volume), or SLA-driven (before the morning BI peak). The rule that matters is that **you never run it continuously** — it is a full read-and-rewrite of the data, so a permanently-running compaction is a permanently-doubled bill.
**By layer:** bronze gets frequent minor compaction because it absorbs the write volume; silver gets scheduled major compaction so downstream reads are clean; gold is usually rebuilt rather than updated, so there is nothing to compact.
The formulations
Minor compaction, frequentlyship
merge log files with each other
base files untouched -> fewer deltas per read
Cheap relief for a hot landing table. Run it hourly.
Major compaction, on a scheduleship
merge deltas into base, rewrite full files
-> reads touch one clean file
Expensive, best result. Nightly, or before the BI peak.
Continuous compactionavoid
compact on every write
You have paid Copy-on-Write costs and kept Merge-on-Read complexity.
"Compaction fixes the small-file problem." Related but not the same mechanism. Small-file compaction merges many small *data* files into fewer large ones. Merge-on-Read compaction merges *delta logs* into base files to remove a read-time merge. A table can need one, the other, or both.
They’ll ask next
A downstream job is reading the table while a major compaction commits. What does it see?
EvergreenBatch vs streamingSLAs & observabilityCDC & ingestion patterns
You need to ingest 200,000 events per second at about 2 KB each. How do you size the stream, and what would you check besides the total?
Why they ask this
The multiplication is trivial. The question is whether you know that total throughput can be comfortably within capacity while the pipeline is still throttled.
Say this
Total throughput is 400 MB/s, which sets a floor on partition count. But partitions are assigned by key hash, so you also have to check the distribution of keys — one hot key saturates one partition regardless of headroom elsewhere.
The reasoning
**The arithmetic first.** 200,000 events × 2 KB ≈ 400 MB/s. Divide by the per-partition write limit your platform gives you — Kinesis shards are 1 MB/s or 1,000 records/s, whichever binds first, and here the record limit binds first: 200,000 records/s needs at least 200 shards, while 400 MB/s needs 400. So 400, plus headroom. Say which limit binds; that is the part being tested.
**Then the distribution, which is what people miss.** Capacity is per partition and assignment is by key hash. If one merchant produces 15% of the traffic, that 60 MB/s lands on a single partition with a 1 MB/s ceiling — throttled, while the stream as a whole sits at 40% utilisation. Total capacity being sufficient tells you nothing about whether any individual partition is.
**So the checks are:** total throughput against the ceiling; **per-key concentration** — the top few keys as a share of the whole; **key cardinality**, because you cannot use more partitions than you have distinct keys; and the growth curve, because both the total and the skew move.
**The signals to watch afterwards** are producer-side: write throttling and retry rate climbing are the leading indicators, and they show up long before anything downstream notices. On the consumer side it is iterator age or lag. Rising producer retries with total throughput well under capacity is the specific signature of a hot key rather than an undersized stream — and the fix is different, which is why the distinction matters.
The formulations
Size from the binding limitship
200_000 rec/s ÷ 1_000 rec/s per shard = 200
400 MB/s ÷ 1 MB/s per shard = 400 <- binds
Two limits, and you size for whichever binds first.
Check per-key concentrationship
top 10 keys as % of traffic;
one key > 1/partition-count means a hot partition
The check that catches the failure total throughput hides.
Size from the total onlyavoid
400 MB/s / 1 MB/s = 400 shards. Done.
Correct arithmetic, throttled pipeline, and no idea why.
The answer most people give
"Total throughput is under capacity, so we are fine." Capacity is enforced per partition. A stream at 40% overall utilisation throttles all day if one key is pinned to one saturated partition, and the dashboard showing 40% is why nobody finds it.
They’ll ask next
Producer retries are rising but total throughput is well under capacity. What is happening?