A one-line prompt — ingest clickstream and serve it to analysts within an hour — and a whiteboard. Graded on the boundaries you draw and the assumptions you state, not on the tools you name.
CDC out of an operational database, files from a vendor, a rate-limited API, a mobile SDK. Four sources that need four different designs, and the reasons why.
Where the guarantees live
5
Every arrow carries a delivery guarantee, a latency and an ordering assumption. These are the designs where getting one of the three wrong shows up in a table.
Layers, storage & serving
5
Bronze to gold, table formats, where metric definitions sit, and how many copies of the same rows a platform ends up holding.
Orchestration & operability
5
The DAG across forty sources, what a lineage graph is actually for, GDPR erasure in a lake, and surviving the loss of a region.
Evergreen · asked verbatim
5
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”.
Design a pipeline for 40M mobile events a day, served to analysts in Snowflake within an hour. The SDK retries on network failure and cannot guarantee a unique event_id per send. Which single line of the design makes it correct?
The architecture and the run — work it out before reading on
Sources
mobile SDK
40M events/day. Buffers offline, retries on failure, no unique event_id guarantee.
Landing
Kafka: events
7-day retention, partitioned by session_id.
idempotent
raw_pageview (Delta)
Append-only. Every record as received, partitioned by ingest date.
idempotent
Transform
fct_pageview (Delta)
MERGE on event_id, so a redelivery is a no-op.
idempotent
Serving
Snowflake / analysts
Complete by 10 past the hour, 99% of days.
mobile SDK → Kafka: eventsat-least-once~2sThe SDK retries, so the same event can be produced twice.
Kafka: events → raw_pageview (Delta)at-least-once~5 minStructured Streaming commits offsets after the write, so a restart replays.
raw_pageview (Delta) → fct_pageview (Delta)at-least-oncehourlyReads with an overlap on purpose; the MERGE absorbs it.
fct_pageview (Delta) → Snowflake / analystsexactly-onceimmediateExternal table, so publishing is a metadata operation.
Every hop is at-least-once. Nothing duplicates, because the MERGE absorbs a repeat.
fct_pageview starts empty. stg_pageview holds one delivery at a time.
The load — run once per delivery
INSERT INTO fct_pageview
SELECT event_id, session_id, path FROM stg_pageview s
WHERE NOT EXISTS (SELECT 1 FROM fct_pageview f WHERE f.event_id = s.event_id);
The deliveries, in order
1the consumer processes a batch
stg_pageview — delivery 1
event_id
session_id
path
ev1
s1
/home
ev2
s1
/pricing
ev3
s2
/home
2the consumer restarts and the broker redelivers it
Same hop, same redelivery. The only thing that changed is the write.
stg_pageview — delivery 2
event_id
session_id
path
ev1
s1
/home
ev2
s1
/pricing
ev3
s2
/home
Why they ask this
The standard opener for a data engineering system design round. The shape is common property; what is graded is whether you can point at the one decision the correctness of the whole thing rests on.
Say this
Kafka for capture and replay, Delta for an append-only raw layer, a MERGE on event_id into the fact, Snowflake reading it. The line that makes it correct is the MERGE: every hop above it is at-least-once, so the write has to absorb a repeat.
The reasoning
Take the hops in order. The SDK retries, so the same event reaches Kafka more than once. Structured Streaming commits offsets after writing, so a restart replays the micro-batch. The hourly load into the fact reads with a deliberate overlap so nothing falls through the boundary. Every one of those is at-least-once, and none is a fault — it is simply what these components offer.
So duplicates arrive at the fact table routinely, and the design does not try to prevent them upstream because it cannot. It absorbs them at the write: MERGE INTO fct_pageview USING ... ON t.event_id = s.event_id. The run below delivers the same batch twice and the table is left identical, and equal to a full recompute over everything delivered.
The missing event_id guarantee is the part to raise unprompted, because it is the assumption the MERGE depends on. If the client cannot mint a stable id, derive one deterministically from the fields that identify the event — session id, event type, client timestamp — and hash them. A random UUID minted at send time changes on every retry and defeats the entire design, which is the specific mistake worth naming.
The run — 2 deliveries, in order replay changed nothing · matches a full refresh, verified
The same batch delivered twice, into the keyed MERGE.
1after the consumer processes a batch
fct_pageview
event_id
session_id
path
ev1
s1
/home
ev2
s1
/pricing
ev3
s2
/home
2after the consumer restarts and the broker redelivers it
fct_pageview
event_id
session_id
path
ev1
s1
/home
ev2
s1
/pricing
ev3
s2
/home
And the same table, recomputed from scratch over everything ever delivered
SELECT DISTINCT event_id, session_id, path FROM delivered ORDER BY event_id
full refresh
event_id
session_id
path
ev1
s1
/home
ev2
s1
/pricing
ev3
s2
/home
The answer most people give
"Enable exactly-once semantics in Kafka, then duplicates cannot happen." That covers Kafka-to-Kafka processing. Your consumer writes to Delta, which is a different system, so the guarantee has ended before the row reaches the table you care about.
They’ll ask next
The SDK cannot mint a stable event_id at all. What do you key the MERGE on?
Same pipeline, one change: the fact table is written with df.write.format('delta').mode('append'). Every box is identical. What breaks, and when would anyone find out?
The architecture and the run — work it out before reading on
Sources
mobile SDK
40M events/day, retries on failure.
Landing
Kafka: events
7-day retention.
idempotent
Transform
fct_pageview (Delta)
df.write.format('delta').mode('append').save(...)
not idempotent
Serving
Snowflake / analysts
Dashboards read this directly.
mobile SDK → Kafka: eventsat-least-once~2s
Kafka: events → fct_pageview (Delta)at-least-once~5 minStructured Streaming replays from its last checkpoint on restart, and append cannot absorb it.
One flagged edge. Otherwise identical to the design in the previous question.
fct_pageview starts empty. stg_pageview holds one delivery at a time.
The load — run once per delivery
INSERT INTO fct_pageview SELECT event_id, session_id, path FROM stg_pageview;
The deliveries, in order
1the consumer processes a batch
stg_pageview — delivery 1
event_id
session_id
path
ev1
s1
/home
ev2
s1
/pricing
ev3
s2
/home
2the consumer restarts and the broker redelivers it
The hop is at-least-once. This is normal operation, not an incident.
stg_pageview — delivery 2
event_id
session_id
path
ev1
s1
/home
ev2
s1
/pricing
ev3
s2
/home
Why they ask this
It is the most common defect in a whiteboard design and the hardest to see, because a diagram draws an at-least-once edge exactly like every other edge.
Say this
Every Structured Streaming restart replays its last micro-batch and append cannot absorb it, so the table gains duplicates. Nothing errors, no task goes red, and it is usually found by an analyst querying a number that is too high.
The reasoning
The defect is a pairing, not a component. An at-least-once hop is fine. An appending write is fine. Together they mean the pipeline duplicates data under conditions guaranteed to occur — a deploy, a node loss, an OOM, any of which restarts the stream from its last checkpoint.
A diagram cannot show it, because every arrow looks the same. The habit that catches it is to annotate each edge with its delivery guarantee and each stage with whether it is idempotent, then read the pairs: an at-least-once edge into a non-idempotent stage is a defect, every time. Here it is exactly one edge, and it is flagged above.
The run below is the same batch and the same redelivery as the previous question with only the write changed: three page views become six, and the table no longer matches a recompute. As for when anyone finds out — there is no error to alert on, so the answer is whenever someone compares the number against another source, which in most organisations is weeks.
The run — 2 deliveries, in order replay duplicated rows · drifts from a full refresh, verified
Same architecture, same redelivery, appending write.
1after the consumer processes a batch
fct_pageview
event_id
session_id
path
ev1
s1
/home
ev2
s1
/pricing
ev3
s2
/home
2after the consumer restarts and the broker redelivers it
fct_pageview
event_id
session_id
path
ev1
s1
/home
ev1
s1
/home
ev2
s1
/pricing
ev2
s1
/pricing
ev3
s2
/home
ev3
s2
/home
And the same table, recomputed from scratch over everything ever delivered
SELECT DISTINCT event_id, session_id, path FROM delivered ORDER BY event_id
full refresh
event_id
session_id
path
ev1
s1
/home
ev2
s1
/pricing
ev3
s2
/home
The answer most people give
"Delta is ACID, so the write is safe." ACID means the append commits atomically. It says nothing about whether appending the same rows a second time is correct, and it is not.
They’ll ask next
Give me two fixes that do not require touching Kafka, and say what each costs.
12 MySQL tables, ~4M row changes a day, into an Iceberg lakehouse. The DBA will grant exactly one replication slot. Design it, and say what bronze holds that silver does not.
The architecture — read it before answering
Sources
MySQL: orders
Operational. 12 tables, ~4M row changes/day. The DBA allows one replication slot.
Landing
Debezium connector
Reads the binlog. Emits op, binlog position, before and after per row.
idempotent
Kafka: cdc.orders.*
One topic per table, keyed by primary key so per-key order holds.
idempotent
bronze_orders (Iceberg)
Append-only change log. Every op row kept, including deletes.
not idempotent
Transform
silver_orders (Iceberg)
Current state. MERGE on order_id at max binlog position; op='D' deletes the row.
idempotent
Serving
Trino / BI
Reads silver. Sees current state only.
MySQL: orders → Debezium connectorat-least-once~5sReplication lag. The slot backs up if the consumer stalls.
Debezium connector → Kafka: cdc.orders.*at-least-once~1sRedelivers from the last committed offset after a restart.
Kafka: cdc.orders.* → bronze_orders (Iceberg)at-least-once~2 minBronze is append-only by design, so it collects the redeliveries. That is what it is for.
bronze_orders (Iceberg) → silver_orders (Iceberg)at-least-once15 minMERGE keyed on order_id, ordered by binlog position.
Bronze keeps every change including duplicates; silver is one row per order.
Why they ask this
CDC into a lakehouse is the most commonly asked ingestion design, and the bronze-versus-silver distinction is where candidates who have only read about it come apart.
Say this
Debezium reads the binlog into one Kafka topic per table keyed by primary key; bronze appends every change row including deletes; silver MERGEs to current state. Bronze holds history and duplicates, silver holds one row per entity.
The reasoning
Bronze is an append-only log of change events: op, binlog position, before and after images, one row per change. It is deliberately not idempotent — the connector redelivers after a restart and bronze keeps both copies, which is exactly what makes replay possible. It is also the only place a delete's before image survives, which matters for audit and for rebuilding history later.
Silver is current state: MERGE on the primary key, taking the row with the highest binlog position per key, and applying op='D' as a delete rather than filtering it out. That last part is the most-skipped detail in CDC design — dropping delete rows because they arrive with nulls in the after image leaves cancelled records alive downstream forever.
The single replication slot is the constraint that shapes everything else. One connector serves all twelve tables, so a stall on any one topic backs up the slot and the binlog grows on a production database — which is exactly why the DBA is cautious. Monitor replication lag and slot size as first-class metrics, size Kafka retention so a consumer outage does not force a re-snapshot, and key topics by primary key so per-key ordering survives parallel consumption.
The answer most people give
"Point Debezium straight at silver and skip bronze." Then a bug in the MERGE is unrecoverable — the change stream has aged out of Kafka and the source's binlog is long gone, so there is nothing left to replay from.
They’ll ask next
The connector stalls for six hours and Kafka retention is seven days. What do you check first, and what is the worst case?
A vendor drops one CSV an hour to SFTP. Sometimes it is late, sometimes the same file arrives twice, and occasionally a file includes the previous hour's rows as well. Design the ingestion.
The architecture — read it before answering
Sources
vendor SFTP
One CSV per hour, usually by :15. Sometimes late, sometimes sent twice, occasionally including the previous hour's rows.
Landing
S3 landing
Raw object, never modified. Key is vendor/dt=.../hour=.../<filename>.
idempotent
file_ledger (Postgres)
One row per filename + checksum. Written before the load, read to skip repeats.
idempotent
Transform
stg_vendor (Delta)
MERGE on the vendor's record_id, so an overlapping file is harmless.
idempotent
Serving
BI dashboard
Freshness check: alert if no file has landed for 90 minutes.
vendor SFTP → S3 landingat-most-oncehourlyIf the vendor does not send, nothing errors. Absence is the only signal.
S3 landing → file_ledger (Postgres)at-least-onceon arrivalS3 event notification, which can fire twice for one object.
file_ledger (Postgres) → stg_vendor (Delta)at-least-once~2 minThe ledger skips a filename already loaded; the MERGE covers overlapping rows.
stg_vendor (Delta) → BI dashboardexactly-onceimmediate
A ledger against the same file, a MERGE against the same rows, an alert against silence.
Why they ask this
File-based vendor feeds are the least glamorous and most common ingestion problem, and each of those three behaviours needs a different defence. Naming all three is the signal.
Say this
Land the object untouched in S3, keep a ledger of filenames and checksums to skip a repeated file, MERGE on the vendor's record_id so overlapping rows are harmless, and alert on absence because a missing file raises nothing.
The reasoning
Three problems, three defences, and they are not interchangeable. A repeated *file* is caught by the ledger — one row per filename and checksum, written before the load and consulted before processing. A repeated *row* inside a differently-named file is not caught by the ledger at all and needs the MERGE on record_id. Lateness is caught by neither and needs an expectation.
That last one is what people miss. If the vendor simply does not send, nothing fails: no error, no red task, no bad data — an hour with no file looks identical to a quiet hour. Detecting absence requires an explicit check, which is why that first edge is annotated at-most-once and the design carries a 90-minute freshness alert. Absence is the one failure mode here that your own code cannot see.
Two things to add unprompted. Keep the raw object forever and never edit it, so a parsing bug is fixable by reprocessing rather than by asking the vendor to resend. And validate the shape before the MERGE — column count, types, a row count inside a plausible band — because a vendor changing their export without telling you is the other thing this feed does.
The answer most people give
"Deduplicate on the filename." That catches the same file sent twice and misses the overlapping-rows case entirely, which is the one that inflates the numbers — the second file has a different name and genuinely new rows alongside the repeats.
They’ll ask next
The vendor starts including a full 24-hour window in every file. Does your design still hold?
A REST API holds 10M records, pages 1,000 at a time, and rate-limits you to 5 requests a second. How do you build the extract so a failure at minute twenty is not a restart?
Why they ask this
API extraction looks trivial until the numbers are on the table. The interviewer wants the arithmetic done out loud and then a design for resumability rather than for the happy path.
Say this
Ten thousand pages at five a second is about 33 minutes, so it will be interrupted. Checkpoint the cursor after every page, write each page to raw as you go, and honour Retry-After rather than guessing a backoff.
The reasoning
Do the arithmetic first: ten million rows at a thousand per page is ten thousand requests, at five a second that is 2,000 seconds — a little over half an hour of uninterrupted calling. Anything running that long will be interrupted, so resumability is a requirement rather than a refinement.
That means checkpointing. Persist the pagination cursor after each successful page and write that page's rows straight to the raw layer, so a failure at minute twenty resumes at page 6,000 rather than page one. Cursor-based pagination is strongly preferable to offset-based here: with offsets, rows inserted during your half-hour shift every subsequent page and you silently skip records.
On the rate limit, read the response headers rather than sleeping a fixed interval. A 429 with Retry-After tells you exactly how long to wait, and honouring it is both faster and politer than a guess. Add jitter so retries do not synchronise, cap total attempts so a genuinely broken endpoint fails loudly instead of retrying all night, and treat 4xx other than 429 as fatal — retrying a 400 burns the budget and delays the alert.
The formulations
Cursor pagination, checkpoint per pageship
while cursor:
page = get(cursor) # honour Retry-After on 429
write_raw(page.rows)
save_checkpoint(page.next) # resumable from here
cursor = page.next
Resumes at the page it died on, and cursors are immune to rows inserted mid-extract.
Parallel workers over disjoint key rangesworks
worker i: GET ?id_from=lo_i&id_to=hi_i
-- only where the API supports a stable key filter
Cuts wall clock when the API allows it, at the cost of consuming the shared rate limit faster.
Offset pagination, full restart on failureavoid
for offset in range(0, 10_000_000, 1000):
rows = get(f'?limit=1000&offset={offset}')
Offsets shift when rows are inserted during the run, so records are silently skipped.
The answer most people give
"Run it in parallel to go faster." The rate limit is global, so parallel workers share the same five requests a second and finish no sooner — they just collect 429s and spend the budget on retries.
They’ll ask next
The API offers only offsets, and rows are inserted while you extract. What now?
MySQL to Debezium to Kafka to a Spark job on a five-minute trigger to an Iceberg table to a dashboard that caches for sixty seconds. Replication lag runs about five seconds and the connector flushes every second. A sale must be visible within fifteen minutes. Does it meet that, and which hop would you attack first?
The architecture — read it before answering
Sources
MySQL: orders
Operational. 12 tables, ~4M row changes/day. The DBA allows one replication slot.
Landing
Debezium connector
Reads the binlog. Emits op, binlog position, before and after per row.
idempotent
Kafka: cdc.orders.*
One topic per table, keyed by primary key so per-key order holds.
idempotent
bronze_orders (Iceberg)
Append-only change log. Every op row kept, including deletes.
not idempotent
Transform
silver_orders (Iceberg)
Current state. MERGE on order_id at max binlog position; op='D' deletes the row.
idempotent
Serving
Trino / BI
Reads silver. Sees current state only.
MySQL: orders → Debezium connectorat-least-once~5sReplication lag. The slot backs up if the consumer stalls.
Debezium connector → Kafka: cdc.orders.*at-least-once~1sRedelivers from the last committed offset after a restart.
Kafka: cdc.orders.* → bronze_orders (Iceberg)at-least-once~2 minBronze is append-only by design, so it collects the redeliveries. That is what it is for.
bronze_orders (Iceberg) → silver_orders (Iceberg)at-least-once15 minMERGE keyed on order_id, ordered by binlog position.
Bronze keeps every change including duplicates; silver is one row per order.
Why they ask this
Latency requirements are agreed per component and breached end to end. The interviewer wants the addition done out loud, including the hops nobody draws.
Say this
Typical lands near seven minutes, so it meets it with room. The fifteen-minute silver MERGE is the largest term and the first thing to attack — and the sixty-second dashboard cache is the hop most designs forget to count at all.
The reasoning
Add them from the topology: about five seconds of replication lag, a second of connector flush, two minutes into bronze, up to fifteen minutes waiting for the silver MERGE, and up to sixty seconds of dashboard cache. Typical sits under ten minutes and the worst case brushes the requirement. Say a number out loud — an interviewer is checking you can produce one at all.
Then say why typical is the less useful figure. What breaches an SLA is the worst case, and these terms do not vary independently: replication lag grows under write load, the MERGE takes longer when there is more to merge, and both peak at the same moment. Correlated stretching is why the sum of typical cases understates the risk.
Attack the largest term. Dropping the silver MERGE from fifteen minutes to five removes ten minutes of budget; optimising a one-second connector flush cannot remove more than a second however hard you work. And measure end to end rather than per hop: carry the source commit timestamp through to the serving table and report the distribution, because every component can sit inside its own budget while the total does not.
The answer most people give
"Every step is fast, so we are fine." Steps are agreed individually and breached collectively, and they stretch together rather than independently — which is why the worst case is not the sum of the typical cases.
They’ll ask next
The requirement moves to two minutes. What changes, and what does it cost to run?
In the CDC chain above, a customer's status is updated 2x within 1 second. Which hops guarantee those two changes are applied in the order they happened, and what do you do about the ones that do not?
The architecture — read it before answering
Sources
MySQL: orders
Operational. 12 tables, ~4M row changes/day. The DBA allows one replication slot.
Landing
Debezium connector
Reads the binlog. Emits op, binlog position, before and after per row.
idempotent
Kafka: cdc.orders.*
One topic per table, keyed by primary key so per-key order holds.
idempotent
bronze_orders (Iceberg)
Append-only change log. Every op row kept, including deletes.
not idempotent
Transform
silver_orders (Iceberg)
Current state. MERGE on order_id at max binlog position; op='D' deletes the row.
idempotent
Serving
Trino / BI
Reads silver. Sees current state only.
MySQL: orders → Debezium connectorat-least-once~5sReplication lag. The slot backs up if the consumer stalls.
Debezium connector → Kafka: cdc.orders.*at-least-once~1sRedelivers from the last committed offset after a restart.
Kafka: cdc.orders.* → bronze_orders (Iceberg)at-least-once~2 minBronze is append-only by design, so it collects the redeliveries. That is what it is for.
bronze_orders (Iceberg) → silver_orders (Iceberg)at-least-once15 minMERGE keyed on order_id, ordered by binlog position.
Bronze keeps every change including duplicates; silver is one row per order.
Why they ask this
Ordering assumptions are made implicitly and violated silently. It is the second most common design defect after an unabsorbed at-least-once hop.
Say this
Only the Kafka topic, and only because it is keyed by primary key — which buys per-key ordering and nothing more. Everything after it can reorder, so the MERGE has to order by binlog position rather than by arrival.
The reasoning
Walk it hop by hop. MySQL's binlog is totally ordered, so Debezium reads the two changes in sequence. Kafka preserves order within a partition, and because the topic is keyed by primary key both changes for that customer land in the same partition — so per-key order survives that hop. Across partitions there is no ordering at all, and there does not need to be.
After that it stops. A Spark job reading the topic processes partitions in parallel and writes a micro-batch with no ordering guarantee between rows; a restart replays a batch alongside newer data. By the time rows reach the MERGE, arrival order means nothing, and any apply taking the last row per key by arrival will sometimes write the older value.
The fix is to stop depending on it. Every change record carries its binlog position, so the MERGE selects the row with the maximum position per key. The result is then a function of the set of records received rather than of the order they arrived in, and reordering becomes harmless. Keying the topic is still worth doing — it makes the common case cheap — but correctness should not rest on it, because the day someone repartitions the topic is not the day to discover the dependency.
The answer most people give
"Kafka guarantees ordering." Within a partition. A consumer group reading twelve partitions in parallel interleaves them arbitrarily, which is exactly the situation where the assumption gets made.
They’ll ask next
Two changes to the same row carry the same binlog position. How could that happen, and what does your MERGE do?
A currency conversion in gold has been wrong for 6 months, across ~180 daily partitions. What has to already be true of the platform for the fix to be an afternoon rather than a quarter?
The architecture and the run — work it out before reading on
Sources
12 source systems
Postgres, Salesforce, Stripe, an internal API.
Landing
bronze (Delta)
One table per source, as received. Partitioned by ingest date, never edited.
idempotent
Transform
silver (Delta)
Cleaned, typed, deduplicated, keys conformed. One table per business entity.
idempotent
gold (Delta)
Facts and dimensions at a declared grain. What consumers are allowed to query.
idempotent
Serving
BI, ML, reverse ETL
Three consumers, one definition of each metric.
12 source systems → bronze (Delta)at-least-oncevaries
bronze (Delta) → silver (Delta)at-least-oncehourlyWhere deduplication happens, which is what lets bronze stay append-only.
Three layers, each earning its place: fidelity, reuse, a consumption contract.
Before anything arrives
raw_pageview
day
event_id
0 rows
The load — run once per delivery
INSERT INTO raw_pageview SELECT day, event_id FROM stg_pageview;
DELETE FROM fct_daily_pageviews WHERE day IN (SELECT DISTINCT day FROM stg_pageview);
INSERT INTO fct_daily_pageviews
SELECT day, COUNT(DISTINCT event_id) FROM raw_pageview
WHERE day IN (SELECT DISTINCT day FROM stg_pageview)
GROUP BY day;
The deliveries, in order
12026-03-01, first delivery
stg_pageview — delivery 1
day
event_id
2026-03-01
ev1
2026-03-01
ev2
22026-03-01 again, overlapping the first
Overlap is expected: the reader is inclusive so nothing can fall through.
stg_pageview — delivery 2
day
event_id
2026-03-01
ev2
2026-03-01
ev3
3and the whole window replayed during a reprocessing run
stg_pageview — delivery 3
day
event_id
2026-03-01
ev2
2026-03-01
ev3
Why they ask this
Reprocessing capability is decided at design time and needed at the worst possible moment. It is the question that separates a design from a diagram.
Say this
An immutable bronze layer that still holds every record as received, and transforms that are pure functions of a date range so re-running them lands the same result. With both it is a parameterised rerun; without either it is a re-extraction project.
The reasoning
The first requirement is that the input still exists. If bronze holds every record as received, the fix is to correct the transform and recompute — no source is involved, no vendor retention policy matters, and it does not matter that the operational database purged six months of history. That is what an append-only raw layer is for and why it is worth its storage.
The second is that the transform accepts a range. A job that processes yesterday because yesterday is hard-coded cannot be pointed at last March without an edit, and editing production code during an incident is how a second incident starts. The window belongs in the interface; the nightly schedule is then that same function applied to yesterday.
The run below demonstrates the third property, which is the subtle one. Raw is append-only and deliberately not idempotent — it collects overlapping deliveries and keeps every copy. The aggregate above it counts distinct events and rebuilds whole partitions, so replaying a window converges on the same answer and matches a full recompute. Raw is allowed to duplicate precisely because the layer above it is written not to care — and had the aggregate used COUNT(*) instead of COUNT(DISTINCT), the replay would have inflated it.
The run — 3 deliveries, in order replay changed nothing · matches a full refresh, verified
Overlapping deliveries and a full replay, against an aggregate that counts distinct.
1after 2026-03-01, first delivery
fct_daily_pageviews
day
events
2026-03-01
2
raw_pageview
day
event_id
2026-03-01
ev1
2026-03-01
ev2
2after 2026-03-01 again, overlapping the first
fct_daily_pageviews
day
events
2026-03-01
3
raw_pageview
day
event_id
2026-03-01
ev1
2026-03-01
ev2
2026-03-01
ev2
2026-03-01
ev3
3after and the whole window replayed during a reprocessing run
fct_daily_pageviews
day
events
2026-03-01
3
raw_pageview
day
event_id
2026-03-01
ev1
2026-03-01
ev2
2026-03-01
ev2
2026-03-01
ev2
2026-03-01
ev3
2026-03-01
ev3
And the same table, recomputed from scratch over everything ever delivered
SELECT day, COUNT(DISTINCT event_id) AS events FROM delivered GROUP BY day ORDER BY day
full refresh
day
events
2026-03-01
3
The answer most people give
"We would re-extract from the source." Sources purge history, change shape, and sometimes belong to a vendor you no longer pay. The raw layer exists so this answer never depends on someone else's retention policy.
They’ll ask next
Reprocessing 180 days would take a week at full speed. How do you actually run it?
The Flink job above scores transactions against a customer risk tier held in a Delta dimension that changes a few times a day. How do you do the join, and which version of the tier should a transaction be scored against?
The architecture — read it before answering
Sources
payments service
Emits an event per authorisation.
Landing
Kafka: transactions
The single capture point. Both paths read it independently.
idempotent
raw_transaction (Delta)
Append-only, for replay and reprocessing.
idempotent
Transform
Flink: fraud scoring
Keyed state per card, sub-second. Blocks before the authorisation settles.
idempotent
fct_transaction (Delta)
Hourly MERGE on transaction_id.
idempotent
Serving
fraud decision API
Sub-second requirement. Real.
finance reporting
Daily, reconciled against the ledger.
payments service → Kafka: transactionsat-least-once~50ms
Kafka: transactions → Flink: fraud scoringat-least-once~200msCheckpointed state; a restart replays from the last checkpoint.
Kafka: transactions → raw_transaction (Delta)at-least-once~5 minThe same events, captured once and read twice.
One capture, two serving paths. Only the fraud path pays for streaming.
Why they ask this
Stream-to-table joins are asked constantly and answered badly. The interesting part is which version of the dimension a given event sees, not the mechanics of the lookup.
Say this
Broadcast the dimension into the job's state and refresh it periodically, or do a temporal join keyed on the event's own timestamp. The second is reproducible; the first is cheaper and quietly scores some events against a tier that was not yet in force.
The reasoning
The naive design looks the dimension up per event, which means a network call per record at the point in the system with the tightest latency budget. It works in testing and falls over at production rates. The standard alternative is to broadcast the dimension into the job's state and refresh it on an interval — fast, no per-event lookup, and it raises a question nobody asks: which version does an event see?
With a periodic refresh, the answer is whichever version happened to be loaded when the event was processed. That is fine for a display attribute and wrong for anything audited, because reprocessing the same events tomorrow produces different scores. A temporal join — matching the event against the dimension version valid at the event's own timestamp — makes the result reproducible, and it requires the dimension to carry validity ranges, which is a Type 2 modelling decision made much earlier.
So name the trade rather than picking blindly. Broadcast with a refresh interval when the attribute is stable and the score is not audited; temporal join against a versioned dimension when the result must be reproducible or explainable to a regulator. And watch the restart in both cases: the job rebuilds its broadcast state from the current dimension, so a replay of yesterday's events scores them against today's tiers unless the join is temporal.
The answer most people give
"Just query the Delta table per event." A network round trip per transaction, at the tightest latency budget in the system — it passes a test with ten events and fails at ten thousand a second.
They’ll ask next
The risk tier changed at 14:00 and you reprocess yesterday tomorrow. Which tier should a 13:00 transaction be scored against?
The same Kafka transaction events must block fraud in under 1 second and reconcile finance daily. One pipeline or two, and where exactly do they diverge?
The architecture — read it before answering
Sources
payments service
Emits an event per authorisation.
Landing
Kafka: transactions
The single capture point. Both paths read it independently.
idempotent
raw_transaction (Delta)
Append-only, for replay and reprocessing.
idempotent
Transform
Flink: fraud scoring
Keyed state per card, sub-second. Blocks before the authorisation settles.
idempotent
fct_transaction (Delta)
Hourly MERGE on transaction_id.
idempotent
Serving
fraud decision API
Sub-second requirement. Real.
finance reporting
Daily, reconciled against the ledger.
payments service → Kafka: transactionsat-least-once~50ms
Kafka: transactions → Flink: fraud scoringat-least-once~200msCheckpointed state; a restart replays from the last checkpoint.
Kafka: transactions → raw_transaction (Delta)at-least-once~5 minThe same events, captured once and read twice.
One capture, two serving paths. Only the fraud path pays for streaming.
Why they ask this
It tests whether you can serve two very different requirements without either over-building for the slow one or under-building for the fast one.
Say this
One capture, two serving paths, diverging at the Kafka topic. Both read the same events so they cannot disagree about what happened, and only the fraud path pays for streaming infrastructure.
The reasoning
Building everything to the fraud requirement means running Flink with managed state to produce a daily finance table — always-on operation and reprocessing-as-a-project for something a scheduled job serves. Building everything to the finance requirement gives the fraud team hourly data, which is useless for blocking an authorisation. Both mistakes are real and both get made.
Capturing once and forking at the serving layer resolves it. Kafka is the single capture point; Flink reads it for sub-second scoring; a streaming job reads the same topic into an append-only Delta table, from which an hourly MERGE builds the fact. The property that matters most is not cost — it is that both paths derive from the same captured events, so they cannot disagree about which transactions occurred. Two separate captures of one source is the arrangement that eventually produces two different answers to one question.
Watch the boundary, because this is where Lambda's problem creeps back in. If the fraud path starts computing business aggregates that also exist in the warehouse — daily totals, per-merchant volumes — there are now two implementations of one definition and they will drift. Keep the streaming path to what genuinely needs the latency and let everything analytical come from one place.
The answer most people give
"Make it all streaming, then both requirements are met." They are, at the cost of always-on infrastructure and reprocessing-as-a-project for tables a scheduled job would have served — and finance perceives no benefit at all.
They’ll ask next
The fraud team now wants a 30-day spend feature per card. Which path serves it, and why not the other one?
12 sources landing in bronze, cleaned into silver, modelled into gold. Justify each layer with a property it makes possible — and say when you would build only two.
The architecture — read it before answering
Sources
12 source systems
Postgres, Salesforce, Stripe, an internal API.
Landing
bronze (Delta)
One table per source, as received. Partitioned by ingest date, never edited.
idempotent
Transform
silver (Delta)
Cleaned, typed, deduplicated, keys conformed. One table per business entity.
idempotent
gold (Delta)
Facts and dimensions at a declared grain. What consumers are allowed to query.
idempotent
Serving
BI, ML, reverse ETL
Three consumers, one definition of each metric.
12 source systems → bronze (Delta)at-least-oncevaries
bronze (Delta) → silver (Delta)at-least-oncehourlyWhere deduplication happens, which is what lets bronze stay append-only.
Three layers, each earning its place: fidelity, reuse, a consumption contract.
Why they ask this
Layer counts get copied from vendor diagrams. The interviewer wants each layer earned by something it enables, not by a naming convention.
Say this
Bronze earns immutability and replay, gold earns a stable consumption contract. Silver earns reuse — and with one source and one mart, silver is a rename of bronze and should not exist.
The reasoning
Bronze's property is fidelity: every record as received, never edited, so a transformation bug is fixed by recomputing rather than by re-extracting. Gold's property is a consumption contract: a declared grain and conformed keys that do not move when a source changes shape. Those two are what a platform cannot do without, and a two-layer platform is a legitimate design.
Silver earns its place through reuse. With twelve sources feeding several marts, the cleaning, typing, deduplication and key conforming is shared work, and doing it once is obviously right — the logic lives in one place and each mart stays readable. With one source and one mart, silver is a rename of bronze: a table nobody reads, a build nobody needs, and an extra hop in every lineage diagram.
Be specific about the cost, because that is what makes this a judgement rather than a convention. Each layer is another materialisation, another scheduled build, more storage, more latency between source and consumer, and more for a newcomer to hold in their head. **What would change my answer**: a second consumer needing the same cleaning is usually the moment silver stops being premature.
The answer most people give
"Always use the medallion architecture, it is the standard." It is a naming convention for a sound idea, not an instruction to build three layers — copying the count without the reasoning produces silver tables that are SELECT * FROM bronze.
They’ll ask next
Where exactly does deduplication happen in this design, and why not one layer earlier?
Your silver and gold tables could be Iceberg on S3 read by Trino and Spark, or native Snowflake tables. The analysts use SQL; the ML team uses Spark. Which, and what does the other option cost you?
Why they ask this
The capability gap has closed enough that reciting 2019 talking points is immediately visible. What still differs is worth being precise about.
Say this
With a Spark consumer, open table formats — one copy both engines read natively. Warehouse-native means either exporting a second copy for ML or pulling through a connector, and both are worse than the maintenance Iceberg asks for.
The reasoning
Concede what no longer differs: both give ACID transactions, time travel, schema evolution and good SQL, and both scale past what most organisations need. Arguments built on those distinctions are out of date and interviewers notice.
What decides it here is the second consumer. Iceberg on object storage means Spark, Trino, Flink and a warehouse read the same files, so the ML feature pipeline and the dashboards run against one copy. Warehouse-native means the ML team either pulls through a connector — slow, and it burns warehouse compute on a workload that is not SQL — or you export a second copy to object storage, which is the thing everyone regrets: two copies, two freshnesses, eventually two answers.
The cost of the open format is operational and real: compaction, file sizing, snapshot expiry, orphan file cleanup, and a catalogue to run. A warehouse does all of that invisibly, which is worth a great deal to a small team. **What would change my answer**: if the ML team disappeared and every consumer were SQL, Snowflake-native is the right call and the maintenance you took on buys nothing.
The formulations
Iceberg on S3, read by Trino and Sparkship
silver/gold as Iceberg tables + REST catalogue;
Snowflake reads them as external tables
One copy, read natively by every engine. Earns its maintenance the moment a non-SQL consumer exists.
Warehouse-native tablesship
silver/gold as Snowflake tables, no catalogue to run
No compaction, file sizing or snapshot expiry to schedule. Correct when every consumer is SQL.
Warehouse-native plus a nightly export for MLavoid
COPY INTO 's3://.../ml_export/' FROM gold_fct;
Two copies with different freshness, and eventually two answers to the same question.
The answer most people give
"Iceberg, because it avoids vendor lock-in." Openness is real and it is not free — you have taken on compaction, snapshot expiry and catalogue operations, which is lock-in to your own headcount instead of to a vendor.
They’ll ask next
You pick Iceberg. Name the maintenance jobs you now have to schedule.
Three consumers read your gold layer: a BI tool, a reverse-ETL sync into Salesforce, and an ML feature pipeline. Where does the definition of net revenue live so all three agree, and what breaks at each alternative?
The architecture — read it before answering
Sources
orders service
Owned by another team. Wants to rename amount to amount_gross next sprint.
orders.v1 contract
Published subset: order_id, status, amount, updated_at. Versioned, with a deprecation window.
Landing
raw_orders (Delta)
Contract asserted here, before anything downstream runs.
idempotent
Transform
dbt: 240 models
Lineage emitted per run from the manifest: inputs, outputs, column references.
idempotent
Serving
OpenLineage catalogue
Answers 'who reads orders.amount' for the producing team, before they change it.
BI + reverse ETL
The consumers the catalogue can name.
orders service → orders.v1 contractexactly-oncen/aThe seam. Left of it they change freely.
dbt: 240 models → OpenLineage catalogueat-least-onceper runEmitted by the run, not maintained by hand, or it rots.
dbt: 240 models → BI + reverse ETLexactly-onceimmediate
Impact analysis is the point: the producer can see who breaks before they break it.
Why they ask this
It is a modelling question with an architectural answer, and the failure it prevents — several definitions of one metric — is the most common complaint about a data platform.
Say this
In the gold layer or a semantic layer above it: downstream of the conformed facts, upstream of all three consumers. Defined in the BI tool it forks the moment the sync needs it; defined per consumer it drifts as each is maintained separately.
The reasoning
The requirement is that three consumers resolve the same number, so the definition has to sit at a point all three pass through — downstream of the conformed facts, because it needs modelled data to be defined against, and upstream of every serving surface, because that is what makes it shared.
Each alternative forks along a predictable line. In the BI tool it is invisible to the reverse-ETL sync, so whoever builds that sync reimplements it — and a salesperson looking at Salesforce sees a different revenue figure from the dashboard, which is precisely the incident that destroys trust in a platform. Repeated per consumer it drifts as each is maintained separately. In the source system it is consistent and needs another team's release cycle to change.
The honest caveat is that this only pays for itself with more than one consumer. With a single BI tool, defining metrics in it is entirely reasonable and a semantic layer is ceremony. **What would change my answer**: one consumer and no near-term second — then the BI tool is the right place, and you revisit when the second arrives.
The answer most people give
"Define it in the BI tool, that is where people ask the question." Fine until the reverse-ETL sync needs the same number and someone reimplements it — and then two systems disagree for reasons nobody can locate.
They’ll ask next
Finance and marketing genuinely need different definitions of revenue. How does your design express that without producing two definitions of one name?
Kafka retains 7 days, bronze holds full history, silver is derived, and the BI tool caches its own extract. That is four copies of the same rows. Justify each, and say which is the dangerous one.
The architecture — read it before answering
Sources
MySQL: orders
Operational. 12 tables, ~4M row changes/day. The DBA allows one replication slot.
Landing
Debezium connector
Reads the binlog. Emits op, binlog position, before and after per row.
idempotent
Kafka: cdc.orders.*
One topic per table, keyed by primary key so per-key order holds.
idempotent
bronze_orders (Iceberg)
Append-only change log. Every op row kept, including deletes.
not idempotent
Transform
silver_orders (Iceberg)
Current state. MERGE on order_id at max binlog position; op='D' deletes the row.
idempotent
Serving
Trino / BI
Reads silver. Sees current state only.
MySQL: orders → Debezium connectorat-least-once~5sReplication lag. The slot backs up if the consumer stalls.
Debezium connector → Kafka: cdc.orders.*at-least-once~1sRedelivers from the last committed offset after a restart.
Kafka: cdc.orders.* → bronze_orders (Iceberg)at-least-once~2 minBronze is append-only by design, so it collects the redeliveries. That is what it is for.
bronze_orders (Iceberg) → silver_orders (Iceberg)at-least-once15 minMERGE keyed on order_id, ordered by binlog position.
Bronze keeps every change including duplicates; silver is one row per order.
Why they ask this
Copies accumulate without anyone deciding. The dangerous one is usually the one users actually look at, and candidates rarely count it.
Say this
Kafka buys replay and fan-out, bronze buys reprocessing, silver buys query performance and a stable shape. The BI extract is the dangerous one: nobody designed it, it has its own freshness, and it is what users see.
The reasoning
Three of the four are deliberate and each has a property attached. Kafka's short retention exists so several consumers read the same events independently and so a consumer can rewind. Bronze's full history exists so a transform bug is fixable by recomputing. Silver exists because consumers need a modelled shape that performs and does not move. Three copies with three reasons is a healthy design, not waste.
The fourth is the problem. BI tools cache their own extracts, and that copy has its own refresh schedule, its own failure mode and its own freshness — and it is the one on screen when somebody says the dashboard is wrong. A great deal of debugging time goes into pipelines that were entirely healthy while a tool-side extract sat two days stale.
So the discipline is that every copy gets a reason, a retention and an owner. Copies without those three accumulate quietly: a team exports to a spreadsheet, someone materialises a convenience table, a notebook writes results back to the lake. Each is reasonable alone, and collectively they produce a platform where nobody can say which number is authoritative.
The answer most people give
"Storage is cheap, extra copies do not matter." Storage is not the problem — four things that can disagree about one number is, and users read whichever their tool happens to have cached.
They’ll ask next
Which of these would you delete first, and what would you check before you could?
A right-to-erasure request arrives for 1 customer. Your bronze layer is append-only Delta holding 7 years at 40M rows a day, and the customer id appears throughout. You have 30 days. How do you design so this is not a seven-year rewrite?
The architecture — read it before answering
Sources
erasure request
A customer id and a legal deadline, typically 30 days.
Landing
raw_events (Delta)
Append-only, 7 years, 40M rows/day. The expensive place to delete from.
not idempotent
pii_vault
customer_id -> token. The only place the identifier is resolvable.
idempotent
Transform
silver / gold (Delta)
Carries the token, never the raw identifier.
idempotent
Serving
BI + exports
Downstream copies, all tokenised.
erasure request → pii_vaultexactly-onceimmediateDelete the mapping. Every token downstream becomes permanently unresolvable.
erasure request → raw_events (Delta)at-least-oncebatchedDeleting from append-only history is the expensive path the vault exists to avoid.
pii_vault → silver / gold (Delta)exactly-oncen/aTokenisation happens before landing, so the raw value is never in these tables.
silver / gold (Delta) → BI + exportsexactly-onceimmediate
Crypto-shredding: delete one vault row instead of rewriting seven years of history.
Why they ask this
Right-to-erasure against an immutable lake is a genuine architectural tension, and the answer has to be designed years before the request arrives.
Say this
Crypto-shredding: tokenise the identifier before it lands, keep the only token-to-value mapping in a vault, and erase by deleting the vault row. Every downstream token becomes permanently unresolvable without touching a history file.
The reasoning
The tension is real. Bronze is append-only because that is what makes reprocessing possible, and erasure requires removal. Doing it literally means finding and rewriting every affected Parquet file across seven years — which DELETE plus VACUUM will do, but the rewrite is enormous, it runs again for every request, and it invalidates time travel over the range it touches.
The design that avoids it makes sure the raw identifier never lands. Tokenise at ingestion — deterministically, so joins still work — and keep the only mapping in a separate vault. Erasure is then a delete of one vault row: tokens throughout bronze, silver and gold remain, nothing can resolve them to a person, and every downstream copy is covered at once including the ones you have forgotten about.
Two things a reviewer will raise. Deterministic tokenisation is weaker than random — identical values are visibly identical, so a low-cardinality field leaks through frequency analysis, which is acceptable for a customer id and poor for a postcode. And crypto-shredding is a legal argument as much as a technical one: your position is that the data is no longer personal because it can no longer be attributed. That has to be agreed with legal before you rely on it, not after the first request.
The answer most people give
"Run DELETE FROM bronze WHERE customer_id = ? then VACUUM." It works, and it rewrites every file containing that customer across seven years, per request, and breaks time travel over the affected range. It is the fallback, not the design.
They’ll ask next
Legal will not accept tokenisation as erasure. What is your plan, and what does it cost per request?
40 ingestion sources feed 240 dbt models. One Airflow DAG, one per source, or something else — and how does a model that depends on three sources know when to run?
Why they ask this
It is the orchestration design question, and the answer turns on how cross-source dependencies are expressed rather than on how the DAGs are filed.
Say this
One DAG per source for ingestion, and a transformation run triggered by data availability rather than by a clock. A model waiting on three sources should wait on those three datasets, not on a time that is late enough.
The reasoning
Ingestion and transformation have different shapes and should not share a DAG. Each source has its own schedule, failure mode and owner, so one DAG per source keeps failures attributable and lets a broken vendor feed fail without touching the other thirty-nine. One giant DAG makes every failure look like the same failure.
Transformation is one dependency graph and dbt already knows it, so the useful arrangement is for Airflow to trigger dbt and let dbt's graph handle the 240 models rather than reimplementing 240 tasks. What Airflow contributes is the trigger, the retries, and visibility across systems dbt cannot see.
The cross-source dependency is the real question. A model needing three sources should wait on those three having landed, not on 03:00 being late enough — a time offset is a guess about someone else's duration and fails silently the night one runs long. Express it as data availability: dataset-triggered scheduling, an asset-based orchestrator, or sensors with timeouts. Then put a deadline on it, so a source that never arrives becomes an SLA alert rather than a DAG waiting forever.
The formulations
One DAG per source, transform triggered by datasetsship
dag ingest_stripe -> outlets=[Dataset('stripe_raw')]
dag transform -> schedule=[stripe_raw, orders_raw, ...]
task: dbt build --select state:modified+
Failures stay attributable, and the cross-source dependency is data rather than a time offset.
One DAG per source, transform on a cron offsetworks
ingest DAGs 01:00-02:30; transform DAG at 03:00
Simple and works most nights. Fails silently the night one ingestion runs long.
One DAG containing everythingavoid
single DAG: 40 ingest tasks + 240 model tasks
Every failure looks the same, one broken vendor blocks the graph, and the UI is unusable.
The answer most people give
"Create one Airflow task per dbt model so we get task-level visibility." You now maintain 240 task definitions duplicating a graph dbt already computes, and every new model needs an Airflow change — the visibility is better obtained from dbt's own artifacts.
They’ll ask next
One source is late every third night. What does your design do, and what does the on-call engineer see?
The orders team wants to rename amount to amount_gross next sprint. How does your platform answer who depends on that column before they ship it, rather than after?
The architecture — read it before answering
Sources
orders service
Owned by another team. Wants to rename amount to amount_gross next sprint.
orders.v1 contract
Published subset: order_id, status, amount, updated_at. Versioned, with a deprecation window.
Landing
raw_orders (Delta)
Contract asserted here, before anything downstream runs.
idempotent
Transform
dbt: 240 models
Lineage emitted per run from the manifest: inputs, outputs, column references.
idempotent
Serving
OpenLineage catalogue
Answers 'who reads orders.amount' for the producing team, before they change it.
BI + reverse ETL
The consumers the catalogue can name.
orders service → orders.v1 contractexactly-oncen/aThe seam. Left of it they change freely.
dbt: 240 models → OpenLineage catalogueat-least-onceper runEmitted by the run, not maintained by hand, or it rots.
dbt: 240 models → BI + reverse ETLexactly-onceimmediate
Impact analysis is the point: the producer can see who breaks before they break it.
Why they ask this
Lineage is a declared capability on most platforms and an unused one on most. The question is what it is actually for, and impact analysis is the answer.
Say this
Column-level lineage emitted by the runs themselves, exposed in a catalogue the producing team can query. The contract makes the answer authoritative; the lineage makes it findable before the change rather than after the incident.
The reasoning
Two things have to exist. A contract saying which columns are published and supported — so there is an authoritative answer to whether amount is even in scope — and lineage saying which downstream models and dashboards actually read it. Without the first you are negotiating from nothing; without the second the producer has no way to find out who they would break.
The lineage has to be emitted by the runs, not maintained by hand. dbt produces a manifest describing model inputs, outputs and column references on every build; OpenLineage carries the same idea across engines. Anything curated manually is accurate the day it is written and rots from then on — which is worse than nothing, because people trust it.
Then the process makes it usable: the producer queries the catalogue before the change, sees the models and dashboards touching that column, and the rename becomes an announced deprecation — add amount_gross, keep amount populated for a window, remove it once consumers migrate. That is expand-migrate-contract, and it is only possible because the question who reads this had an answer that took a minute rather than a week.
The answer most people give
"We would search the codebase for the column name." That finds SQL in the repo and misses dashboards, notebooks, reverse-ETL mappings and anything built by another team — which is exactly where the breakage lands.
They’ll ask next
Your lineage covers dbt but not the BI layer. What do you do about the dashboards?
Your lake is replicated to a second region and eu-west-1 becomes unavailable during the morning run. Given the design above, walk through what you can and cannot do.
The architecture — read it before answering
Sources
sources
Unaffected by your region choice.
Landing
eu-west-1 lake (primary)
2.4 TB. All ingestion writes here.
idempotent
eu-central-1 lake (replica)
Cross-region replication. Storage only — no compute runs here today.
idempotent
Transform
Airflow + dbt (primary)
Deployed in eu-west-1 only. Connections, variables and secrets live here.
idempotent
Serving
warehouse / BI
RPO 24h, RTO 8h — the two numbers the design is built to.
sources → eu-west-1 lake (primary)at-least-oncevaries
eu-west-1 lake (primary) → eu-central-1 lake (replica)at-least-once~15 minObject replication. Copies bytes and nothing else.
eu-west-1 lake (primary) → Airflow + dbt (primary)at-least-oncehourly
eu-central-1 lake (replica) → Airflow + dbt (primary)at-most-oncen/aNothing reads the replica today, which is why nobody knows whether it works.
The bytes are replicated. The orchestrator, secrets and compute are not.
Why they ask this
Disaster recovery is agreed in principle and tested never. The interviewer wants to hear which parts were replicated and which quietly were not.
Say this
You can read the data — the bytes are there, at most fifteen minutes behind. You cannot run anything, because Airflow, its metadata database, the connections and the secrets are all single-region. Recovery time is however long rebuilding those takes.
The reasoning
The replication covers object storage and nothing else, which is the usual situation and the usual surprise. The data exists in eu-central-1, at most fifteen minutes stale — that is your effective recovery point and it is fine. What does not exist is the ability to do anything with it: no scheduler, no metadata database holding run history and connections, no secrets, no compute, and no catalogue pointing at the replicated files.
So recovery time is dominated by rebuilding the platform rather than by moving data — and it is unknown, because nobody has done it. An untested failover is a document, and the day you need it is the worst day to discover that the Airflow connections were configured by hand two years ago and exist nowhere in version control.
The questions that come before any of this are the recovery objectives. RPO 24 hours and RTO a week is a nightly copy and almost nothing else. RPO minutes and RTO an hour means warm compute, infrastructure as code deployed to both regions, replicated secrets, a catalogue that points at both, and regular failover drills — a different order of cost. Ask for those two numbers before designing, because they decide everything else.
The answer most people give
"Cross-region replication is enabled, so we are covered." It covers the objects and none of the platform. You can read the files and cannot run a single pipeline, which is not a recovery.
They’ll ask next
What is the cheapest change that would meaningfully cut the recovery time here?
The business asks for an SLA on fct_orders. The vendor upstream publishes some time before 06:00 and will not commit further. What do you offer, and how do you phrase it so it can actually be breached?
Why they ask this
Freshness promises are made loosely and measured never. The upstream constraint is the interesting half — you are being asked to commit to something you do not control.
Say this
A complete-by time on a named table against a percentile, with a stated definition of complete — and it has to sit far enough after 06:00 to absorb the vendor. Anything tighter than their behaviour allows is not yours to promise.
The reasoning
A usable SLA has four parts: a named table, because a platform-wide promise means nothing; a complete-by deadline rather than a frequency, because hourly does not say when the hour ends; a percentile, because 100% is never true and promising it means the promise gets ignored; and a definition of complete, which everyone omits and which decides every later argument.
Complete is genuinely ambiguous with late data. All events up to midnight, or all events received by the deadline? The second is achievable and the first is not, and the gap between them is the late-arrival tail. Stating which you mean — typically that recent partitions are provisional and restated on a rolling window — is what stops the SLA becoming a dispute the first time a number moves.
Then the constraint. You cannot offer 06:30 when the vendor publishes some time before 06:00, because you would be committing to their behaviour. Measure their actual publication times for a few weeks, offer a deadline their 99th percentile supports, and separately raise the vendor's own SLA as a commercial conversation. Committing first and negotiating afterwards is how a data team acquires a reputation for missing deadlines it never controlled.
The formulations
Named table, deadline, percentile, definitionship
fct_orders complete by 07:30 on 99% of days;
complete = all vendor rows received by 07:00;
last 3 days provisional, restated on a rolling window
Specific enough to be measured and breached, and it absorbs the vendor's stated behaviour.
Deadline derived from measured upstream behaviourship
measure vendor publish time for 4 weeks
-> set the deadline at their p99 + run duration
Turns a promise about someone else's system into one about a distribution you have observed.
A frequencyavoid
-- the whole commitment
"fct_orders is refreshed hourly"
Says nothing about when an hour ends or what happens when a run is late. Unbreachable, so unenforceable.
The answer most people give
"We refresh hourly." That is a schedule, not a commitment — nobody can tell whether it has been breached, and it says nothing about what happens when the vendor is two hours late.
They’ll ask next
Your SLA is breached because the vendor was late. What does the alert say, and who does it go to?
For the vendor-file pipeline above, name what you would measure at each stage — and say why a green Airflow task is the weakest signal of the lot.
The architecture — read it before answering
Sources
vendor SFTP
One CSV per hour, usually by :15. Sometimes late, sometimes sent twice, occasionally including the previous hour's rows.
Landing
S3 landing
Raw object, never modified. Key is vendor/dt=.../hour=.../<filename>.
idempotent
file_ledger (Postgres)
One row per filename + checksum. Written before the load, read to skip repeats.
idempotent
Transform
stg_vendor (Delta)
MERGE on the vendor's record_id, so an overlapping file is harmless.
idempotent
Serving
BI dashboard
Freshness check: alert if no file has landed for 90 minutes.
vendor SFTP → S3 landingat-most-oncehourlyIf the vendor does not send, nothing errors. Absence is the only signal.
S3 landing → file_ledger (Postgres)at-least-onceon arrivalS3 event notification, which can fire twice for one object.
file_ledger (Postgres) → stg_vendor (Delta)at-least-once~2 minThe ledger skips a filename already loaded; the MERGE covers overlapping rows.
stg_vendor (Delta) → BI dashboardexactly-onceimmediate
A ledger against the same file, a MERGE against the same rows, an alert against silence.
Why they ask this
Task-level monitoring is what teams have; data-level monitoring is what they need. Every silent failure in this subject runs green.
Say this
At the source, absence — nothing arriving raises nothing. At landing, freshness, a row-count band and the schema assertions. At transform, grain uniqueness and reconciliation. Task status only says the code did not raise.
The reasoning
The vendor edge is the hard one, because its failure mode is silence. If no file is sent, no task fails, no error is logged and no bad data appears — the hour simply has no file, which looks exactly like a quiet hour. Only an explicit expectation catches it, which is why that edge is annotated at-most-once and the design carries a 90-minute freshness alert. Absence is the failure your own code cannot see.
At landing, measure what the producer promised: did the columns and types arrive as agreed, is the row count inside a rolling band, how old is the newest record. At transform, measure your own logic: is the grain still unique, do totals reconcile against the layer below, are null rates on important columns stable. At serving, measure the commitment — the complete-by time, and whether anyone reads the table at all.
Task status comes last because it measures the wrong thing: whether code raised an exception. A duplicated file loaded twice, a MERGE keyed above the grain, a dropped delete and a watermark skipping a row all complete successfully. Data-level checks are the only ones positioned to see any of them, and reconciliation against an independent recomputation is the only one positioned to see all of them.
The answer most people give
"All the tasks are green, so the pipeline is healthy." Green means the code did not raise. Every silent failure in this section produces a green task, which is exactly what makes them dangerous.
They’ll ask next
Which single check would you add first to a pipeline that currently has none?
EvergreenCDC & ingestion patternsBatch vs streamingIdempotency & exactly-once
You are putting events on Kafka or Kinesis. How do you choose the partition key, and what makes a key a bad one?
Why they ask this
It is the first real design decision in any streaming pipeline and it is irreversible in practice — you cannot rekey a stream without replaying it.
Say this
Choose it from the ordering you need: the entity whose events must stay in sequence. Bad keys are the ones with too few distinct values — a constant, a status, a country — because they collapse the stream onto a few partitions.
The reasoning
One choice decides four things at once, which is why it gets so much airtime. **Ordering scope** — records with the same key land on the same partition and are read in sequence, so ordering is guaranteed *per key*, never globally. **Distribution** — the key is hashed, so its cardinality determines how evenly load spreads. **Hotspot risk** — one heavy key means one saturated partition. **Scalability** — you cannot have more useful parallelism than you have distinct keys.
Derive it from the ordering requirement and the rest follows. An order pipeline uses `order_id`, so created → packed → shipped → delivered arrive in order for each order. A user-activity stream uses `user_id`. A payment lifecycle uses `payment_id`. In each case the entity whose state machine must not be observed out of sequence is the key.
The bad keys fail for the same underlying reason. A **constant** (`"all_events"`) sends everything to one partition — a stream with the throughput of a single consumer. A **low-cardinality attribute** — country, status, event type — gives a handful of buckets, and real distributions are not uniform, so one of them takes most of the traffic. A **timestamp** distributes acceptably but orders nothing useful, because two events for the same order land wherever their timestamps hash to.
When one legitimate key runs hot — a merchant generating a hundred times the traffic of any other — you can bucket it as `order_id#0..9`. Be explicit that this is a **trade, not a fix**: you have bought throughput by giving up ordering across the buckets, and that is only acceptable if the business can tolerate it. If strict per-entity ordering is a requirement, the answer is a stable key and a conversation about capacity, not a silent salt.
The formulations
The entity that needs orderingship
key = order_id # all events for one order, in sequence
key = user_id # one user's activity stays ordered
High cardinality and it matches the ordering requirement. Both boxes ticked.
A constant keyavoid
key = "all_events" # every record, one partition
Total ordering, and the throughput of exactly one partition.
A low-cardinality attributeavoid
key = country # or status, or event_type
A few buckets, unevenly filled. One partition takes most of the traffic.
Bucketed key, deliberatelyworks
key = f"{order_id}#{hash(event_id) % 10}"
Spreads a hot key. You have given up ordering across buckets — say so.
The answer most people give
"Use a random key so the load spreads evenly." Perfect distribution and no ordering at all, which means every downstream consumer now has to reconstruct sequence from timestamps. Random is right only when nothing needs ordering, and that is worth stating rather than assuming.
They’ll ask next
One merchant produces 40% of your traffic and ordering per merchant is required. What do you do?
You are defining the event contract for a new Kafka topic. Beyond the payload, what metadata does every event carry — event_id, event_time, what else — and what does each field buy you later?
Why they ask this
It is the cheapest question to answer badly. Every field here exists because some downstream problem is unsolvable without it, and candidates who have not run a pipeline in production name two.
Say this
An envelope with event_id, event_type, event_time, source, schema_version and a trace id, wrapping an opaque payload. Each one exists to make a specific downstream job possible — dedup, ordering, replay, lineage or debugging.
The reasoning
**`event_id`** — a unique id assigned by the producer. This is what makes deduplication possible at all, and it has to come from the producer because a retry must reuse the same id. A consumer-generated id deduplicates nothing.
**`event_time`** — when the thing happened, distinct from when it arrived. Every windowing and lateness decision downstream depends on having both, and a pipeline that only records ingestion time can never be correct about late data.
**`event_type`** and **`schema_version`** — what this is and which version of the contract it follows. The version is what lets a consumer handle old and new shapes during a rollout instead of breaking at the moment the producer deploys.
**`source`** and **`trace_id`** — which service emitted it, and the request it belonged to. These earn their place the first time someone asks "where did this row come from", and a trace id is what lets you follow one business action across four services and a warehouse.
One rule matters more than the field list: **events are immutable facts, not row updates**. The instinct to publish "the order, changed" is the wrong shape; you publish "an order was updated, and here is its new state or the delta". That is what makes the stream replayable, because replaying a log of facts reproduces the current state, and replaying a log of mutations only works if you also know what the state was.
The envelope pays off again with multiple producers. If every service emits the same wrapper, generic consumers, lineage tooling and debugging all work uniformly instead of per-source.
No id to dedupe on, no version to roll out against, no event time.
Ingestion time onlyavoid
{ ..., "ingested_at": "2026-04-20T12:04:11Z" }
Windows become wrong the moment anything is delayed.
The answer most people give
"The consumer can generate an id by hashing the payload." Two genuinely distinct events with identical content — the same customer buying the same item twice in a second — hash to the same value and one gets discarded as a duplicate. Identity is the producer's to assert.
They’ll ask next
A producer retries after a timeout. Should the retried event carry a new event_id?
Should schema validation happen in the producer before an event is published to Kafka, or downstream in the Spark job that reads it?
Why they ask this
It is a question about where a boundary sits, and the answer "both, but different checks" is what distinguishes someone who has owned a pipeline from someone who has drawn one.
Say this
Cheap contract checks belong at the producer, before anything is published; expensive semantic checks belong downstream. The stream should not become a dump pipe, because everything that enters it reaches every consumer.
The reasoning
The asymmetry is what decides it. A bad record rejected at the producer costs one caller an error. The same record published reaches every consumer, lands in raw storage, and stays in the retention window — so it now has to be handled in every downstream job, forever, including in replays.
What belongs at the producer is everything cheap and structural: mandatory fields present, timestamps parseable, the partition key derivable, payload within the size limit, `schema_version` set. None of these need context and all of them are decidable in the producing service.
What does not belong there is anything requiring state or lookups — referential integrity against a dimension, business thresholds, cross-event consistency. Those need data the producer does not have, and pushing them upstream couples the producing service to the warehouse.
The framing worth using: **the stream is a contract boundary, not a landfill.** You still keep a raw layer and you still expect bad data to get through, because producers have bugs — but the checks that are free at the edge should be paid there.
The formulations
Contract checks at the producership
required fields · parseable timestamp · partition key
present · size within limit · schema_version set
Cheap, stateless, and it stops bad data reaching every consumer at once.
Needs state and history. The producer cannot do these.
No producer-side validationavoid
producer.send(topic, raw_payload)
Every consumer inherits the problem, and so does every replay.
The answer most people give
"Validate everything downstream so the producer stays fast." Two lines of null checks are not what makes a producer slow, and the cost you avoid at the edge is paid by every consumer, in every replay, indefinitely.
They’ll ask next
The producer rejects an event. Where does it go, and who finds out?
Parquet files are immutable. So what actually happens when you run an UPDATE against a Delta or Iceberg table, and how does the engine find the right file among 10 million rows?
Why they ask this
Lakehouse formats are on every job description, and this question separates people who have used one from people who have read that it supports ACID.
Say this
Under Copy-on-Write the engine rewrites the whole data file containing the matching rows and atomically swaps it into the table metadata. It finds the file from per-file min/max statistics and the partition path, not by scanning.
The reasoning
The sequence: locate the files containing matching rows, read each one in full, apply the change in memory, write a new file, and commit a metadata change that removes the old file and adds the new one. The commit is atomic, so a reader sees either the old file or the new one and never a half-written state. **Nothing is edited in place** — "update" is a rewrite plus a pointer swap.
Finding the file is the part people cannot answer, and it is not magic. Every file has an entry in the table metadata carrying its partition values and **min/max statistics for each column**. If File A holds `order_id` 1–1000 and File B holds 1001–2000, an update to `order_id = 1500` touches only File B. Add partitioning and the search narrows before statistics are even consulted: `date=2026-01-02/` is the only directory opened.
This is also why the same update can be cheap or catastrophic depending on the data layout. If the rows you are changing are clustered into a few files, you rewrite a few files. If they are scattered across every file in the table — which is what happens when you update by a column the table is neither partitioned nor sorted on — you rewrite the table.
The trade Copy-on-Write makes: writes are expensive, reads are perfect. There is no merge at query time, the files are clean Parquet, and scan performance is exactly what it would be on a static table. That is why it is the right default for silver and gold layers, where reads outnumber writes by orders of magnitude and a finance dashboard cannot afford unpredictable latency.
The formulations
Copy-on-Writeship
find file -> read it -> apply change ->
write new file -> atomic metadata swap
Expensive writes, clean reads. Right for BI and reporting tables.
File located by statisticsship
File A: order_id 1..1000
File B: order_id 1001..2000 <- order_id = 1500 hits only this
Min/max per file, plus the partition path. No full scan.
Update on a scattered columnavoid
UPDATE orders SET status='x' WHERE customer_id = 42
-- customer_id neither partitioned nor sorted
Matching rows sit in every file, so you rewrite the whole table.
The answer most people give
"Delta keeps a transaction log so it can update rows in place." The log is what makes the swap atomic and gives you time travel; it does not make Parquet mutable. Every update still rewrites whole files.
They’ll ask next
You update one row per file across a thousand files. How much data gets rewritten?
EvergreenBatch vs streamingBackfills & reprocessingData contracts & quality gates
A nightly batch job feeds a critical report and the business now wants it within 5 minutes. How do you get from batch to streaming without a big-bang cutover?
Why they ask this
The architecture is the easy half. The migration is where real projects fail, and interviewers ask it because it is the part nobody rehearses.
Say this
Run both in parallel, compare their outputs on the same inputs until they agree, move consumers over gradually, and keep the batch path as the reconciliation baseline rather than deleting it.
The reasoning
**Build the streaming path beside the batch one, writing somewhere else.** Same sources, same business logic, a separate target. Nothing depends on it yet, so it can be wrong without consequence — which is the only condition under which you will actually find out that it is.
**Compare, and make the comparison a job.** Reconcile the two targets on the same window: row counts, control totals, and a per-key diff on the metrics that matter. This is where the real defects surface, and they are always semantic rather than infrastructural — the batch job deduplicated on a key the stream does not, or handled a late correction the stream drops. Run it daily until the diff is stably empty, not once.
**Move consumers one at a time**, starting with the ones that tolerate being wrong. Each move is reversible while both paths are still running, which is the entire point of the parallel period.
**Keep the batch path after cutover, at least for a while.** It becomes the reconciliation baseline: a nightly full recompute that the streaming target is checked against. That is not indecision, it is the cheapest possible audit — and in a regulated context it is often what makes the streaming path acceptable at all.
The trade to state out loud: for a period you are paying for two pipelines and maintaining two implementations of the same logic, and every business-rule change has to be made twice. That cost is the price of a reversible migration, and it is much smaller than the cost of discovering the streaming numbers were wrong after the batch job was deleted.
Finds the semantic differences while nothing depends on the new path.
Gradual consumer cutovership
move the most tolerant consumer first,
keep both paths live, reverse if the diff reopens
Every step reversible for as long as both run.
Big-bang switchavoid
delete batch DAG; point BI at the streaming table
No baseline to compare against and no way back.
The answer most people give
"Build the streaming pipeline, validate it in staging, then switch." Staging does not have production's late arrivals, duplicates, or the one merchant who sends malformed records on Sundays. The comparison has to run against production traffic, which means both paths have to be live at once.
They’ll ask next
The parallel run shows a 0.02% difference in daily revenue that will not go away. Do you cut over?