Skip to main content
Data Engineering Interviews intermediate Lesson 8 of 10

Data Quality and Incident Questions

The round about the day something went wrong — structuring an incident answer, the six checks worth naming, and finding a discrepancy by bisecting the pipeline.

Every senior data engineering loop has this round. It is scored on structure and on specifics — vague answers about “monitoring and alerting” score badly regardless of how much monitoring you have actually built.

”Tell me about a data incident you handled”

Use six headings, in this order. Most candidates start at diagnosis and skip the rest.

HeadingWhat to sayCommon mistake
Detectionhow you found out, and how long after it started”someone told us” with no follow-up
Impactwho was affected, which numbers, how wrongskipped entirely
Diagnosishow you narrowed it downjumping to the answer
Mitigationwhat you did to stop the bleedingconflated with the fix
Root causethe actual cause, not the symptom”a bad deploy”
Preventionwhat you changed so it cannot recur”we were more careful”

A worked answer, with numbers:

Detection. “Finance flagged that Tuesday’s revenue looked low. Our freshness check had passed, so nothing alerted — that was the first finding.”

Impact. “Three dashboards and the weekly board pack. Revenue understated by £41,000 across four days, about 8%.”

Diagnosis. “I compared row counts layer by layer. Bronze and silver matched the source; gold was short. The join to the customer dimension was dropping rows.”

Mitigation. “Reverted the gold model to the previous version, republished the four days, and told finance which figures had changed before they asked.”

Root cause. “A change swapped a left join for an inner join to ‘clean up’ nulls. Customers created after the dimension’s daily snapshot had no matching row, so their orders vanished.”

Prevention. “Three things: a reconciliation test comparing gold revenue to silver on every run, an anti-join test on the dimension, and a volume check against the trailing seven- day average. The reconciliation test would have caught it on the first run.”

The prevention section is where seniority shows, and the strongest version names which check would have caught it rather than listing checks in general.

Show the bisection

The diagnosis step is worth demonstrating, because it is a technique rather than a story.

import duckdb
con = duckdb.connect()

con.execute("""create table source_orders as select * from (values
    (1001, 1, date '2026-01-04', 'completed', 25.50),
    (1002, 2, date '2026-01-04', 'completed', 12.00),
    (1003, 1, date '2026-01-04', 'returned',  40.00),
    (1004, 3, date '2026-01-04', 'completed',  8.75),
    (1005, 9, date '2026-01-04', 'completed', 19.99),
    (1006, 4, date '2026-01-04', 'completed', 31.20)
) t(order_id, customer_id, ordered_at, status, amount)""")

con.execute("""create table dim_customer as select * from (values
    (1, 'GB'), (2, 'US'), (3, 'GB')
) t(customer_id, country)""")

con.execute("create table bronze as select * from source_orders")
con.execute("create table silver as select * from bronze where status <> 'pending'")
con.execute("""create table gold as
    select s.ordered_at, c.country, sum(s.amount) as revenue
    from silver s join dim_customer c using (customer_id)
    where s.status = 'completed' group by 1, 2""")

print(con.execute("""
    select 'source' as layer, count(*) as rows,
           round(sum(amount) filter (status='completed'), 2) as completed_revenue
      from source_orders
    union all select 'bronze', count(*), round(sum(amount) filter (status='completed'),2) from bronze
    union all select 'silver', count(*), round(sum(amount) filter (status='completed'),2) from silver
    union all select 'gold',   (select sum(1) from gold), (select round(sum(revenue),2) from gold)
""").df().to_string(index=False))
  layer  rows  completed_revenue
 source     6              97.44
 bronze     6              97.44
 silver     6              97.44
   gold     3              77.45

Source, bronze and silver agree at £97.44. Gold is £19.99 short — the divergence is between silver and gold, so the transformation to look at is the one in between, and nothing above it matters.

print(con.execute("""
    select s.order_id, s.customer_id, s.amount
    from silver s left join dim_customer c using (customer_id)
    where c.customer_id is null and s.status = 'completed'
""").df().to_string(index=False))
 order_id  customer_id  amount
     1005            9   19.99
     1006            4   31.20

Two orders reference customers absent from the dimension. £19.99 of the gap is order 1005; order 1006’s £31.20 is missing too — and the arithmetic does not close, which is itself informative:

print(con.execute("""
    select 97.44 as silver_completed,
           (select round(sum(revenue),2) from gold) as gold,
           round(97.44 - (select sum(revenue) from gold), 2) as gap,
           (select round(sum(amount),2) from silver s
             left join dim_customer c using (customer_id)
             where c.customer_id is null and s.status='completed') as orphan_total
""").df().to_string(index=False))
 silver_completed   gold   gap  orphan_total
            97.44  77.45 19.99         51.19

The gap is £19.99 but the orphans total £51.19 — so the numbers do not reconcile and there is a second problem. Following that discrepancy rather than stopping at the first plausible cause is the behaviour being assessed. Being able to say “the arithmetic does not close, so there is more than one issue” is a strong signal.

Bisection generalises: compare a metric at each layer, find the first divergence, look only there. Two minutes, and it beats reading transformation code from the top.

”What checks would you add?”

Name six, with what each catches. Listing check names is mid-level; naming the failure each one catches is senior.

checks = {
  "freshness":      "select max(ordered_at) >= current_date - 1 from silver",
  "volume":         "select count(*) between 0.5*avg_7d and 1.5*avg_7d from ...",
  "unique_key":     "select count(*) = count(distinct order_id) from silver",
  "referential":    "select count(*) = 0 from silver s left join dim_customer c using (customer_id) where c.customer_id is null",
  "accepted_values":"select count(*) = 0 from silver where status not in ('completed','returned','refunded')",
  "reconciliation": "select abs(sum(gold.revenue) - sum(silver completed)) < 0.01",
}
for name, sql in checks.items():
    print(f"{name:<16} {sql[:78]}")
freshness        select max(ordered_at) >= current_date - 1 from silver
volume           select count(*) between 0.5*avg_7d and 1.5*avg_7d from ...
unique_key       select count(*) = count(distinct order_id) from silver
referential      select count(*) = 0 from silver s left join dim_customer c using (custome
accepted_values  select count(*) = 0 from silver where status not in ('completed','returne
reconciliation   select abs(sum(gold.revenue) - sum(silver completed)) < 0.01
CheckCatches
Freshnessthe source stopped arriving
Volume vs trailing averagea truncated file, a partial export, a tightened filter
Unique keyduplicates inflating every downstream sum
Referential integritythe incident above
Accepted valuesan upstream team adding a status without telling anyone
Reconciliationrows lost to a join — what no per-column check can see

Volume is the one candidates omit, and it catches the failure where every row that arrived is individually valid:

con.execute("""create table load_history as select * from (values
    (date '2026-01-01', 4820), (date '2026-01-02', 4712), (date '2026-01-03', 4902),
    (date '2026-01-04', 4688), (date '2026-01-05', 4771), (date '2026-01-06', 4810),
    (date '2026-01-07', 1204)
) t(load_date, rows_loaded)""")

print(con.execute("""
    select load_date, rows_loaded,
           round(avg(rows_loaded) over (order by load_date rows between 6 preceding and 1 preceding)) as trailing_avg,
           round(100.0 * rows_loaded / avg(rows_loaded) over (order by load_date rows between 6 preceding and 1 preceding), 1) as pct
    from load_history order by load_date desc limit 3
""").df().to_string(index=False))
  load_date  rows_loaded  trailing_avg   pct
 2026-01-07         1204          4784  25.2
 2026-01-06         4810          4779 100.6
 2026-01-05         4771          4781  99.8

25% of normal. Every row-level check passes on those 1,204 rows because they are all valid — only the volume check sees a partial load. A threshold expressed as a fraction of the trailing average needs no maintenance as volume grows, which is why it beats an absolute number.

”Block or warn?”

The question behind the question is whether you understand the cost of each.

“Block when publishing would be worse than publishing nothing — a duplicated primary key, a volume collapse, a failed reconciliation. Those corrupt numbers people act on, and stale data is recoverable while wrong data that has been acted on is not. Warn for a known-imperfect source where downstream can cope, and record it either way so the rate is trackable. What I would avoid is a check that only logs — nobody reads it, so it is the appearance of quality rather than quality.”

Then the operational half, which most candidates skip:

con.execute("""create table quality_runs as select * from (values
    (date '2026-01-05', 'referential', 0, 'error'),
    (date '2026-01-06', 'referential', 0, 'error'),
    (date '2026-01-07', 'referential', 2, 'error'),
    (date '2026-01-05', 'accepted_values', 1, 'warn'),
    (date '2026-01-06', 'accepted_values', 1, 'warn'),
    (date '2026-01-07', 'accepted_values', 9, 'warn')
) t(run_date, check_name, failing_rows, severity)""")

print(con.execute("""
    select check_name, run_date, failing_rows,
           round(avg(failing_rows) over (partition by check_name order by run_date
                 rows between 6 preceding and 1 preceding), 1) as trailing
    from quality_runs where run_date = date '2026-01-07'
""").df().to_string(index=False))
      check_name  run_date  failing_rows  trailing
     referential 2026-01-07             2       0.0
 accepted_values 2026-01-07             9       1.0

Alert on the change, not the absolute count. referential going from a trailing zero to two is a new problem; accepted_values at 9 against a trailing 1 is a spike worth investigating. A quality dashboard that fires on absolute thresholds becomes noise everyone mutes within a month.

”How do you prevent a schema change breaking you?”

Two halves, and the second is the one that scores:

Technically — land raw so additive changes flow through, declare types at ingestion so a rename fails loudly rather than producing nulls, and keep the raw payload so a new field is available without a backfill.

try:
    con.execute("select order_id, amount_gbp from source_orders limit 1")
except Exception as e:
    print(type(e).__name__ + ":", str(e).split("\n")[0])
BinderException: Binder Error: Referenced column "amount_gbp" not found in FROM clause!

“That failure is what I want. A schema-inference reader would have given me NULL for every row and the pipeline would have published zeros.”

Organisationally — a data contract checked in the producer’s CI, so a breaking change fails their build rather than your 03:00 run. “Detecting an incompatible schema at read time tells the consumer they are broken, which they already knew. The check has to run earlier than that."

"How do you know a table is trustworthy?”

A short answer that covers a lot of ground:

  • Freshness and volume, visible to consumers, not just to the team.
  • Ownership — a named team, not a person.
  • Lineage — where it came from, so an incident can be traced upstream.
  • A documented grain and SLA — “one row per order line, available by 07:00 UTC”.
  • A quality history — check results over time, so consumers can see the trend rather than trusting a green badge.

The last point is the differentiator: “a table with a visible quality history is more trustworthy than one that has never been checked, even if the second has fewer known problems.”

The scoring

BehaviourSignal
Structured the incident detection → impact → … → preventionsenior
Named which check would have caught itsenior
Bisected layer by layer rather than reading codesenior
Noticed the arithmetic did not close and kept goingsenior
Included volume-against-trend, not just row-level checkssenior
Listed good checks without saying what each catchesmid
”We added more monitoring” with no specificsjunior

Practice

1. Compare a metric across every layer and find the divergence.
  layer  rows  completed_revenue
 source     6              97.44
 silver     6              97.44
   gold     3              77.45

The break is between silver and gold, so only that transformation matters. Two minutes of bisection beats an hour of reading code.

2. Check whether the discrepancy fully reconciles.
 gap 19.99   orphan_total 51.19

The numbers do not close, so there is a second problem. Stopping at the first plausible cause is the most common diagnostic error.

3. Compute volume against a trailing average.
 2026-01-07  1204 rows  trailing_avg 4784  pct 25.2

Every row-level check passes — the rows that arrived are valid. Only volume sees a truncated load, which is why it is the check worth adding first.

4. Compare a check's failing rows with its own trailing average.
      check_name  failing_rows  trailing
     referential             2       0.0
 accepted_values             9       1.0

Zero-to-two is new; one-to-nine is a spike. Alerting on the change rather than the absolute count is what stops a quality dashboard becoming noise.

Next: system design for data — the whiteboard round, scoped and sequenced.

Frequently Asked Questions

How should I structure an answer about a data incident?
Detection, impact, diagnosis, mitigation, root cause, prevention — in that order. Most candidates jump straight to the technical root cause and never say how it was found or who was affected, which is the half that shows seniority.
What data quality checks should every pipeline have?
Freshness, volume against a trailing average, uniqueness of the key, referential integrity, accepted values, and a reconciliation to the source. The last two catch the failures that per-column checks cannot, and volume is the one candidates most often omit.
Should a failed data quality check block the pipeline?
Block when publishing would be worse than publishing nothing — a broken key, a missing day, a volume collapse. Warn when the source is known to be imperfect and downstream can cope. Both must be recorded; a check that only logs is a check nobody sees.
How do you find where a number went wrong?
Bisect the pipeline. Compare row counts and the metric at each layer against the layer below, and the first place they diverge is where to look. It is a two-minute query and it beats reading transformation code from the top.