Make It Reliable: Intermediate
What you will be able to do
| Layer | Runs where | Example check | Failure it catches |
|---|---|---|---|
| 1: Contract | On raw data at ingestion | `order_id` unique, `currency` in a fixed list | Producer changed a column or sent a partial file |
| 2: Transformation | After each transform step | Silver row count within 0.5% of bronze row count | A join fanned out or a filter dropped real rows |
| 3: Business | On the published table | Sum of `order_total` within 30% of the trailing median | A logic bug that passes every structural test |
Blocking versus warning
- Primary key null or duplicated
- Row count outside the reconciliation tolerance
- A required column missing from the source
- Totals negative or outside the business band
- Optional column null rate rises from 2% to 4%
- A new column appears that no consumer reads yet
- Free-text field length distribution shifts
- Row count 3% above the median on a known sale day
Reconciliation as the layer 2 check
The tolerance belongs next to the check, in writing, with every reason rows leave on purpose. This transform removes 2 kinds: the duplicate versions that deduplication collapses on the way into silver, which section 2 builds, and internal test orders. Counting distinct order_id values in bronze takes the duplicates out of the arithmetic, so on a 20 million row day dropped_rows of 1,200 test orders is healthy and the threshold reads 0.5% rather than 0. A tolerance nobody wrote down becomes a 3 a.m. argument about whether 1,200 is a bug.
What blocking costs, in minutes
- Each layer is tied to the failure it catches, so the design shortens diagnosis
- Blocking is the default for keys, counts and invariants, with the asymmetry stated as the reason
- Every tolerance and every blocking check has a written number and a named owner
- Place checks at the 3 layers and say which failure each one catches
- Block on key, count and invariant failures before the publish step
- Write the tolerance beside the check with the reason for it
- Give every blocking check a named owner
- Run one check at the end and call the pipeline validated
- Warn on a null primary key because blocking feels disruptive
- Set tolerance to 0 on a transform that filters rows on purpose
- Page a channel with no owner attached
Picture the orchestrator retrying a task at 02:14. The first attempt wrote 6 million of the day's 20 million rows into silver.orders and then lost its connection. The retry starts from the beginning, reads all 20 million rows from bronze, and appends them. The table now holds 26 million rows for 20 October, 6 million of them twice. Nothing errored on the second attempt, so nothing paged. A pipeline is idempotent when running it 5 times leaves the table in the same state as running it once, and that property is what makes retry safe to turn on.
Pattern 1: overwrite the partition
Two details decide whether this holds. First, the filter must use the run's logical date, which the orchestrator passes in, rather than CURRENT_DATE. A retry at 00:30 the next morning with CURRENT_DATE would delete and rewrite the wrong day. Second, there is a window between the DELETE and the INSERT where a reader sees an empty partition. If consumers query during the load window, write into a staging table and swap it in as the final step, which section 4 builds on.
Pattern 2: MERGE on the business key
Partition overwrite struggles when an update arrives for a row that lives in a partition from 3 weeks ago. Rewriting every partition in that range to apply 200,000 changed rows is wasteful, and change data capture feeds produce exactly that shape of input. MERGE matches incoming rows to existing rows on a key, updates the ones that exist, and inserts the ones that do not. A replay matches every row the first run wrote, and the updated_at guard below skips them, so they stay unchanged. The source has to hold one row per key first: if staging.orders_changes carries 2 versions of one order_id, which change feeds deliver routinely, the MERGE fails on the double match, so run the ROW_NUMBER deduplication from section 2 before merging.
The updated_at guard matters for replays out of order. If the feed redelivers Monday's change after Tuesday's already applied, the guard keeps Tuesday's values. Without it, MERGE is idempotent for a single batch but not for the sequence of batches.
| Property | Partition overwrite | MERGE on key |
|---|---|---|
| Unit of work | One partition, all rows | One row per key |
| Needs a unique key | No | Yes, and it must be stable |
| Late update to an old partition | Rewrites the whole partition | Touches one row |
| Cost on a 50M row partition | About 8 minutes, regardless of how many rows changed | Join cost on the key; 200k changes finish in under 1 minute |
| Cost on a 2B row target table | Unchanged, only the partition is touched | Key join against 2B rows unless the target is partitioned and the merge prunes |
| Reader sees partial state | Yes, between DELETE and INSERT, unless staged | No, each statement commits whole |
The test to say out loud
- ▸
CURRENT_TIMESTAMPorCURRENT_DATEin a filter: the second run selects different rows than the first - ▸Auto-increment surrogate keys: the retry mints new ids for the same business rows
- ▸An append-only sink with no key: nothing in the target lets a run recognise its own earlier output
Exact duplicates and versioned duplicates
SELECT DISTINCT removes rows that are identical in every column. It does nothing for 3 rows that share an order_id but carry different updated_at values, because each row is distinct. That shape, the same key with several versions, is the common one, and it needs a rule that picks the version to keep.
| order_id | order_total | updated_at | ingested_at |
|---|---|---|---|
| 1001 | 49.00 | 2026-10-20 09:12 | 2026-10-20 09:15 |
| 1001 | 54.00 | 2026-10-20 11:40 | 2026-10-20 11:42 |
| 1001 | 54.00 | 2026-10-20 11:40 | 2026-10-20 11:50 |
Row 2 and row 3 share the same updated_at, so the rule needs a second sort key. ingested_at breaks the tie: the latest arrival wins. Without a tie-breaker the engine picks whichever row it reads first, and the pipeline returns different answers on different runs.
Order 1001 comes out once with order_total 54.00 from the row ingested at 11:50. Say why ORDER BY has 2 keys when you write it, because an interviewer who sees one key will ask what happens on a tie, and a candidate who already answered it has shown they ran into it before.
Cross-source deduplication with source priority
| customer_id | updated_at | |
|---|---|---|
| 42 | dana.k@exmaple.com | 2026-10-20 14:00 |
| customer_id | updated_at | |
|---|---|---|
| 42 | dana.k@example.com | 2026-10-20 10:00 |
The CASE in the ORDER BY turns a business rule into a sort key, and the ELSE 3 catches a source nobody listed so it never silently outranks billing. The rule here is whole-row: billing wins every column. The field-level version, where billing wins email but the CRM wins phone, uses a separate priority per column and COALESCE across sources ordered by that priority. Mention that it exists, and say you would start whole-row because consumers can reason about one winner.
Where it runs and what it costs
ROW_NUMBER partitioned by key forces a shuffle of every row to the worker holding its key, then a sort within each key. On a 500 million row daily feed that shuffle is the most expensive step in the load, so run it once, when bronze becomes silver, and leave bronze holding every version so an audit can see what arrived. Deduplicating again in every downstream model repeats the shuffle and invites 2 models to pick different winners for the same key.
- Name the 3 sources of duplicates before writing SQL
- Order by a business timestamp, then an ingestion timestamp, for a deterministic winner
- Encode source priority as a CASE sort key with an ELSE for unknown sources
- Dedupe once at the silver layer and keep every version in bronze
- Reach for DISTINCT when rows differ in a timestamp column
- Order by one key and leave ties to the engine
- Let newest-wins override the system of record
- Dedupe again in every downstream model
Freshness: how old is the newest row
Define the SLA in consumer terms first. The orders table promises the previous day's partition, published by 07:00 each morning, so on 21 October consumers expect the partition for 20 October to be there. The monitor asks exactly that at the deadline: is the newest loaded dt the expected logical date? Store a loaded_at column at write time as well, so the alert can report when the last load landed without scanning event timestamps that may legitimately lag the load.
The WHERE on dt is the cost control. Without it the monitor scans 3 years of partitions on every run to find a maximum that only ever lives in the newest one. With it the query reads only the newest partitions and finishes in seconds, so it can repeat after the deadline until the partition lands. Comparing dates is also what keeps the page meaningful: a threshold on minutes since MAX(loaded_at) would sit above 60 for most of every day on a once-a-day load and page around the clock.
Volume: compare to the right baseline
Schema drift: catch the change before the join does
Monitoring as code
| Signal | Fires when | Action | Why that action |
|---|---|---|---|
| Freshness | Expected day's partition missing at 07:00 | Page the owner | Consumers are already waiting |
| Volume | Count deviates more than 30% from baseline | Open a ticket, hold the publish | Could be real; a human confirms in daylight |
| Schema removed or retyped | Live columns differ from expected | Block the load, page | The transform will fail or produce wrong types |
| Schema added | A new column appears | Warn in channel | Additive; nothing downstream reads it yet |
At 07:10 the orders load finished and published 20 October. The layer 3 sum check had been switched to warn rather than block while a new currency feed settled in, so its failure went to a channel and the publish went ahead. At 07:40 the on-call read that warning: order_total summed 100 times higher than its trailing median. The row count was normal, which is why the volume monitor from section 3 stayed quiet. A currency conversion change had multiplied minor units where it should have divided. Consumers refresh dashboards at 08:00. You have 20 minutes, and the question is whether the design you built gives you a rollback that fits in them.
Design the publish so rollback is a pointer flip
The consumer view points at one versioned table; publish and rollback both change only the pointer
When the bad data already published
| Option | Time to correct state | Requires | Leaves |
|---|---|---|---|
| Restore previous snapshot or version | Seconds to minutes | Versioning or retained snapshots | Yesterday's data until the fix re-runs |
| Re-run the idempotent pipeline on fixed code | One full load, about 40 min here | The overwrite or MERGE design from section 1, plus a fixed transform | Correct data, no manual steps |
| DELETE the bad partition and leave it empty | Seconds | Partition-scoped data | A visible gap that consumers notice rather than a wrong total |
| Patch rows with an UPDATE | Minutes, if the bug is a formula | Knowing exactly which rows and which formula | Risk of a second bug under time pressure |
Test the rollback before you need it
Downstream, and saying so
A rollback on serving.orders leaves every table built from it holding the bad numbers until they re-run. The orchestrator knows the dependency graph, so the rollback step marks downstream tasks for the day as failed and re-triggers them after the fix, in dependency order. The message to consumers goes out before the fix, with 3 facts: which table, which dates, and when the corrected data will be live. A consumer who hears at 07:50 that Tuesday's orders are wrong and will be corrected by 09:00 waits; a consumer who finds out from their own dashboard stops trusting the table.
- Separate the published name from the physical data so publish and rollback are pointer flips
- Audit in staging with the same checks that would block the publish
- Retain previous versions long enough to cover a long weekend
- Re-trigger downstream tasks in dependency order after the fix
- Write a load straight into the table consumers read
- Patch rows by hand at 07:45 under a deadline
- Roll back the one table and forget the tables built on it
- Let consumers discover the problem from their dashboards
> A grocery delivery company loads 18 million order events a day into an orders table that finance, marketing and the operations dashboard read by 07:00. Three tables are built from it: finance's revenue rollup, marketing's cohort table and the operations summary. Events arrive at least once from the app, store systems resend on retry, and the customer record lives in both the CRM and the payments platform. One Tuesday the load fails halfway and retries, a payments schema change renames a column the same week, and a pricing fix lands with the units inverted.
Design loads that block bad data, survive retries, pick one row per key, alert on time, and roll back in seconds.
- Category
- Pipeline Architecture
- Difficulty
- intermediate
- Duration
- 35 minutes
- Challenges
- 2 hands-on challenges
Topics covered: How Do You Ensure Data Quality?, Can You Re-run This Safely?, How Do You Handle Duplicates?, How Do You Know It's Broken?, How Do You Roll Back a Bad Load?
Lesson Sections
- How Do You Ensure Data Quality?
Interviewers ask this one open ended on purpose. They want to hear whether you reach for a structure before you reach for a tool. The structure that holds up is 3 layers of checks, each placed where a different kind of failure originates. The first layer sits at ingestion and tests the raw feed against its contract: the columns the producer promised, their types, and a primary key that is present and unique. The second layer runs after transformation and tests the pipeline's own work: row counts
- Can You Re-run This Safely?
Pattern 1: overwrite the partition The most direct form of idempotency is to make the unit of work a whole partition and replace it. The run deletes everything it owns, which is the one day it is processing, and writes that day again from the source. Run it twice and the second run deletes the first run's 20 million rows before writing its own 20 million. Pattern 2: MERGE on the business key The numbers point at the decision. When changes arrive as whole days and consumers tolerate a staged swap
- How Do You Handle Duplicates?
Duplicates arrive from 3 directions, and the interviewer wants to hear you name them before you write a window function. Upstream systems deliver at least once, so a retry on their side sends the same event twice. Your own retries, when the load is not idempotent, double rows you already wrote. And the same real-world entity lives in 2 systems that both export it, so the customer exists once in the CRM and once in billing with different email addresses. The first 2 produce exact or versioned dup
- How Do You Know It's Broken?
A quality check runs inside the pipeline and asks whether this load is right. A monitor runs beside the pipeline and asks whether the pipeline itself is alive, on time and shaped the way consumers expect. The 3 monitors interviewers look for are freshness, volume and schema, and the answer that lands describes each one as a query with a threshold and an action, checked into the repository next to the pipeline code. Freshness: how old is the newest row Volume: compare to the right baseline Row co
- How Do You Roll Back a Bad Load?
Design the publish so rollback is a pointer flip The pattern is write, audit, publish. The load writes the new partition to a location consumers cannot see, the quality layers from section 0 audit it there, and only a passing audit publishes it by switching what the consumer-facing name points to. Rollback becomes the same switch in reverse: point the name back at the previous version. The write and the audit can take 40 minutes; the publish and the rollback take seconds. Versioned tables with a