Skip to main content
Data Engineering intermediate Lesson 6 of 10

Data Quality Checks That Run Every Load

Six checks worth writing, quarantine instead of dropping, thresholds against a trailing average, and the reconciliation test that catches what column checks cannot.

A quality check is a query that returns the rows that should not exist. If it returns none, the data passed. Everything in this lesson is that one idea, applied at the point where bad data would otherwise become someone’s dashboard.

Setup

import duckdb
con = duckdb.connect("bookshop.duckdb")

con.execute("""
    create or replace table bronze_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),
        (1003,    1, date '2026-01-04', 'returned',   40.00),   -- duplicate
        (1004,    3, date '2026-01-04', 'completed',  -8.75),   -- negative
        (1005, NULL, date '2026-01-04', 'completed',  63.20),   -- null fk
        (1006,    9, date '2026-01-04', 'refunded',   19.99),   -- orphan
        (1007,    2, date '2026-01-04', 'unknown',     5.00)    -- bad enum
    ) t(order_id, customer_id, ordered_at, status, amount)
""")
con.execute("""
    create or replace table customers as
    select * from (values (1,'Ada','GB'),(2,'Grace','US'),(3,'Alan','GB'))
    t(customer_id, full_name, country_code)
""")

Six checks

CHECKS = {
    "unique_order_id": """
        select order_id, count(*) as n from bronze_orders
        group by 1 having count(*) > 1
    """,
    "not_null_customer_id": """
        select order_id from bronze_orders where customer_id is null
    """,
    "accepted_status": """
        select order_id, status from bronze_orders
        where lower(trim(status)) not in ('completed','returned','refunded','pending')
    """,
    "positive_amount": """
        select order_id, amount from bronze_orders where amount <= 0
    """,
    "customer_exists": """
        select o.order_id, o.customer_id from bronze_orders o
        left join customers c using (customer_id)
        where o.customer_id is not null and c.customer_id is null
    """,
    "amount_within_range": """
        select order_id, amount from bronze_orders where amount > 10000
    """,
}

def run_checks(table="bronze_orders"):
    results = []
    for name, sql in CHECKS.items():
        failures = con.execute(sql).fetchall()
        results.append((name, len(failures), failures[:2]))
    width = max(len(n) for n, _, _ in results)
    for name, n, sample in results:
        flag = "PASS" if n == 0 else "FAIL"
        print(f"{flag}  {name:<{width}}  {n:>3}  {sample if n else ''}")
    return sum(n for _, n, _ in results)

total = run_checks()
print(f"\n{total} failing rows")
FAIL  unique_order_id        1  [(1003, 2)]
FAIL  not_null_customer_id   1  [(1005,)]
FAIL  accepted_status        1  [(1007, 'unknown')]
FAIL  positive_amount        1  [(1004, Decimal('-8.75'))]
FAIL  customer_exists        1  [(1006, 9)]
PASS  amount_within_range    0  

5 failing rows

Each check names the rows, not just a count — which is the difference between a failure you can act on and one that starts an investigation.

Note 1002 with COMPLETED passed accepted_status, because the check lowercases. Deciding whether inconsistent casing is a quality failure or a cleaning step is a real choice: here it is cleaning, so the check tolerates it and silver normalises it.

Fail, warn, or quarantine

Not every failure deserves the same response:

SEVERITY = {
    "unique_order_id":      "error",    # breaks every downstream aggregate
    "not_null_customer_id": "error",
    "customer_exists":      "warn",     # known-imperfect source
    "accepted_status":      "warn",
    "positive_amount":      "warn",     # refunds arrive as negatives sometimes
    "amount_within_range":  "warn",
}

def gate():
    errors, warnings = [], []
    for name, sql in CHECKS.items():
        n = len(con.execute(sql).fetchall())
        if n:
            (errors if SEVERITY[name] == "error" else warnings).append((name, n))
    for name, n in warnings:
        print(f"WARN  {name}: {n} rows")
    if errors:
        raise SystemExit(f"BLOCKED: {errors}")
    print("gate passed")

gate()
WARN  customer_exists: 1 rows
WARN  accepted_status: 1 rows
WARN  positive_amount: 1 rows
BLOCKED: [('unique_order_id', 1), ('not_null_customer_id', 1)]

The pipeline stops, and yesterday’s correct marts stay in place rather than being overwritten from a broken load. That is the whole point of a gate: failing to publish is recoverable; publishing wrong numbers is not.

Quarantine instead of dropping

con.execute("""
    create or replace table quarantine as
    select order_id, customer_id, ordered_at, status, amount,
           list_value(
               case when customer_id is null then 'null_customer_id' end,
               case when amount <= 0 then 'non_positive_amount' end,
               case when lower(trim(status)) not in ('completed','returned','refunded','pending')
                    then 'bad_status' end,
               case when customer_id not in (select customer_id from customers)
                    then 'orphan_customer' end
           ).list_filter(x -> x is not null) as reasons,
           now() as quarantined_at
    from bronze_orders
    where customer_id is null
       or amount <= 0
       or lower(trim(status)) not in ('completed','returned','refunded','pending')
       or customer_id not in (select customer_id from customers)
""")

print(con.execute("select order_id, status, amount, reasons from quarantine order by order_id").df().to_string(index=False))
 order_id    status  amount                reasons
     1004 completed   -8.75  [non_positive_amount]
     1005 completed   63.20     [null_customer_id]
     1006  refunded   19.99      [orphan_customer]
     1007   unknown    5.00           [bad_status]
con.execute("""
    create or replace table silver_orders as
    select order_id, customer_id, ordered_at, lower(trim(status)) as status, amount
    from (select *, row_number() over (partition by order_id order by order_id) rn
          from bronze_orders)
    where rn = 1
      and order_id not in (select order_id from quarantine)
""")
print(con.execute("select count(*) from silver_orders").fetchone())
(3,)

Eight rows in, three clean rows out, four quarantined with reasons, one deduplicated. Nothing was lost, and the four bad rows are now a queryable backlog rather than a gap.

print(con.execute("""
    select unnest(reasons) as reason, count(*) as n
    from quarantine group by 1 order by n desc
""").df().to_string(index=False))
              reason  n
 non_positive_amount  1
    null_customer_id  1
     orphan_customer  1
          bad_status  1

That grouping is what you take to the upstream team — a named problem with a count, not “your data is bad”.

Freshness and volume

Column checks cannot see a load that simply did not happen.

con.execute("""
    create or replace 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), 0) 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_of_avg
    from load_history order by load_date desc limit 3
""").df().to_string(index=False))
  load_date  rows_loaded  trailing_avg  pct_of_avg
 2026-01-07         1204        4783.8        25.2
 2026-01-06         4810        4778.6       100.7
 2026-01-05         4771        4780.5        99.8

25% of the trailing average — a partial load. Every check in the previous section passes on those 1,204 rows, because they are individually valid. Volume is the check that catches a truncated file, a source outage, or a filter someone tightened.

def volume_gate(threshold=0.5):
    row = con.execute("""
        select rows_loaded,
               avg(rows_loaded) over (order by load_date rows between 6 preceding and 1 preceding) as avg7
        from load_history order by load_date desc limit 1
    """).fetchone()
    if row[1] and row[0] < row[1] * threshold:
        raise SystemExit(f"volume gate: {row[0]} rows is {row[0]/row[1]:.0%} of the 7-day average")

volume_gate()
volume gate: 1204 rows is 25% of the 7-day average

Freshness is the same shape:

print(con.execute("""
    select max(load_date) as latest,
           datediff('day', max(load_date), current_date) as days_stale
    from load_history
""").df().to_string(index=False))
     latest  days_stale
 2026-01-07         245

Reconciliation

The check that catches what per-column tests cannot — does the mart still agree with its source?

print(con.execute("""
    with source as (select sum(amount) as total from silver_orders where status = 'completed'),
         mart   as (select sum(revenue) as total from gold_daily_revenue)
    select source.total as source_total, mart.total as mart_total,
           round(source.total - mart.total, 2) as difference
    from source, mart
""").df().to_string(index=False))
 source_total  mart_total  difference
        37.50       25.50       12.00

£12 missing, because an inner join in the mart dropped an order whose customer is absent. Every column check passed. Reconciliation tests between a layer and the one below it are the highest-value checks in a pipeline, and the ones most often missing.

Recording quality over time

con.execute("""
    create table if not exists quality_runs (
        run_date date, check_name varchar, failing_rows bigint, severity varchar, checked_at timestamp
    )
""")

def record(run_date):
    for name, sql in CHECKS.items():
        n = len(con.execute(sql).fetchall())
        con.execute("insert into quality_runs values (?, ?, ?, ?, now())",
                    [run_date, name, n, SEVERITY[name]])

record("2026-01-04")

print(con.execute("""
    select check_name, failing_rows,
           avg(failing_rows) over (partition by check_name order by run_date
                                   rows between 7 preceding and 1 preceding) as trailing
    from quality_runs where run_date = '2026-01-04'
    order by failing_rows desc limit 3
""").df().to_string(index=False))
        check_name  failing_rows  trailing
   unique_order_id             1       0.0
 accepted_status               1       0.1
 positive_amount               1       0.9

unique_order_id went from a trailing zero to one — a new problem. positive_amount at 1 against a trailing 0.9 is business as usual. Alerting on the change rather than the absolute count is what stops a quality dashboard becoming noise everyone mutes.

Tools

The patterns above are what dbt tests, Great Expectations, Soda and Databricks pipeline expectations all implement. Reach for one when you want a shared vocabulary and reporting rather than a bespoke script — but the decisions are the same either way: which checks, what severity, and whether a failure blocks publication.

Practice

1. Add a check for orders dated in the future.
CHECKS["no_future_dates"] = "select order_id, ordered_at from bronze_orders where ordered_at > current_date"
FAIL  no_future_dates  1  [(1009, datetime.date(2027, 3, 1))]

Future dates are a classic timezone or parsing bug, and they quietly break every “last 30 days” query by pinning max(date) far ahead.

2. Quarantine bad rows and group by reason.
              reason  n
 non_positive_amount  1
    null_customer_id  1
     orphan_customer  1

Four rows preserved with reasons instead of dropped. That table is also the evidence for the conversation with whoever produces the feed.

3. Make a load 25% of normal volume and run the gate.
volume gate: 1204 rows is 25% of the 7-day average

Every row-level check passed — the rows that arrived were fine. Only the volume check sees a truncated file, and it is usually the first sign of a source-side outage.

4. Reconcile a mart against its source.
 source_total  mart_total  difference
        37.50       25.50       12.00

A £12 gap no column check could find, caused by a join. Add one reconciliation test per mart and you catch the class of bug that survives everything else.

Next: incremental loads and change data capture.

Frequently Asked Questions

What should a data quality check actually test?
Uniqueness of the key, required fields, accepted values, referential integrity, freshness, and volume against a trailing average. Those six catch most real incidents; everything beyond them is domain-specific and should be added when an incident proves it necessary.
Should a failed quality check stop the pipeline?
Stop it when publishing the data 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.
What is quarantining bad rows?
Writing rows that fail validation to a separate table with the reason, instead of dropping them. The good rows still flow, the bad ones stay inspectable, and the count becomes a metric you can chart rather than an invisible gap.
Why compare row counts against a trailing average?
Because absolute thresholds are either too loose or too noisy as volume grows. A rule like 'today is under 50% of the trailing 7-day average' catches a partial load at any scale and needs no maintenance.