Home DevOps Airflow Data Quality: The Rerun That Loaded Data Twice
Advanced 3 min · September 04, 2026
Airflow Data Quality Gates

Airflow Data Quality: The Rerun That Loaded Data Twice

Airflow data quality stops reruns from loading twice with idempotent upserts and gate tasks.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
September 04, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 25 min
  • A DAG that loads data into Postgres or Snowflake
  • Basic SQL including GROUP BY and transactions
  • An Airflow 3.x environment with a staging schema
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Airflow data quality means idempotent upsert loads plus gate tasks that fail the DAG before bad batches reach analysts
  • Key components: natural-key upserts, row-count bands, dedupe checks, partition lineage, and producer data contracts
  • Performance insight: gate queries add under 30 seconds per run while catching 100% of double-loads that once took days to reconcile
  • Production insight: one rerun with no dedupe key doubled millions of rows overnight while every task stayed green
  • Biggest mistake: append-only loads with cleanup in a separate janitor DAG that runs on a different schedule than the loader
✦ Definition~90s read
What is Airflow Data Quality Gates?

Airflow data quality is the practice of idempotent upsert loads plus gate tasks that fail the DAG before bad batches reach analysts. It combines natural-key merges, row-count bands, dedupe checks, and partition lineage.

Think of a librarian shelving returned books.
Plain-English First

Think of a librarian shelving returned books. An idempotent librarian checks whether each book is already on the shelf before adding it, so processing the same returns twice changes nothing. A quality gate is the assistant who counts the cart before shelving and refuses the batch when half the books are missing.

Reruns are inevitable. Someone will click clear, a backfill will overlap, and your load task will run twice with the same data. The only question is whether the second run is a no-op or a disaster.

One team's rerun loaded every row twice. Analytics doubled overnight, the dashboard lied for days, and the cleanup meant reconciling millions of rows by hand. No gate caught it because no gate existed.

You'll build loads that converge on rerun and gates that fail loudly on bad batches. Twice the runs. Same rows.

Idempotency: The Rerun Safety Net

Idempotency means the second run changes nothing. For loads, that means keying every write on the business date plus the natural key and merging instead of appending. Reruns, retries, and backfills all converge on the same table state.

You'll implement it as an upsert in one transaction. Delete the partition's keys, insert the fresh batch, commit together. A crash mid-load leaves the old partition intact instead of a half-written mess.

Test it by running twice. Load Monday's partition, run the same task again, and diff the table. Identical means safe; doubled means the next real rerun becomes an incident.

📊 Production Insight
Reruns are guaranteed by retries, clears, and backfills. Upserts turn repetition into convergence. Rule: every load must survive running twice unchanged.
🎯 Key Takeaway
Same input, same table state, however many times it runs. Upsert in one transaction, prove it with a double-run diff, and reruns become boring.

Quality Checks as First-Class Tasks

Quality checks deserve to be tasks, not notebook queries someone runs monthly. As tasks they block promotion, appear in the Grid view, and page the owner when they fail. Visibility turns checks from folklore into guarantees.

You'll stage first, gate second, promote third. Extract lands in a staging table, gate tasks validate it, and only passing batches merge to serving. Failing batches quarantine in staging with their lineage intact for diagnosis.

Own each gate explicitly. The producing team owns extract freshness, the consuming team owns contract thresholds, and the DAG fails when either side breaks the deal. Shared ownership means shared paging, which means fast fixes.

Use the official GX provider instead of hand-rolled wrappers — it cuts boilerplate roughly 60%. Three operators cover three situations: GXValidateDataFrameOperator for in-memory Spark/pandas frames, GXValidateBatchOperator for data behind a BatchDefinition, and GXValidateCheckpointOperator when you want full Checkpoint power with actions on results. All three take Ephemeral (in-DAG only) or Cloud (persisted, linkable) contexts; only Checkpoint supports File contexts with Data Docs. Set fail_task_on_validation_failure to True on prod gates so bad batches never load — post-hoc warnings get ignored about 60% of the time.

Scale it with dynamic task mapping: keep a tables = [...] list and map one validation task per table instead of copy-pasting DAGs. Validation results land in XCom (use return_json_dict for a serializable dict), so downstream tasks can push null percents to Prometheus or branch on quality scores.

dags/orders_daily.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import pendulum
from airflow.sdk import dag, task
from airflow.providers.postgres.hooks.postgres import PostgresHook

@dag(
    dag_id="orders_daily",
    schedule="0 7 * * *",
    start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
    catchup=False,
    tags=["tier-1", "orders"],
)
def orders_daily():
    @task
    def load_partition(ds: str | None = None) -> str:
        hook = PostgresHook(postgres_conn_id="analytics_warehouse")
        hook.run(
            """
            BEGIN;
            DELETE FROM serving.orders WHERE business_date = %(ds)s;
            INSERT INTO serving.orders
            SELECT * FROM staging.orders WHERE business_date = %(ds)s
            ON CONFLICT (business_date, source_id) DO UPDATE
            SET amount = EXCLUDED.amount, status = EXCLUDED.status;
            COMMIT;
            """,
            parameters={"ds": ds},
        )
        return ds or ""

    load_partition()

orders_daily()
💡Gate Before Promotion
Gates run against staging, before promotion. A gate that runs after analysts query the table is a postmortem with a green checkmark.
📊 Production Insight
Checks outside the DAG get skipped under pressure. Tasks can't be skipped silently. Rule: no passing gate, no promotion to serving.
🎯 Key Takeaway
Stage, gate, then promote, with each gate a real task that blocks serving. Quarantine failures in staging and page both producer and consumer.

Row-Count and Dedupe Gates

Row-count bands catch empty and partial files. Compare today's staged count against the 30-day median; a batch at 1% of median is a broken extract, not a quiet day. You'll tune the band per feed because Black Friday is not a data incident.

Dedupe checks catch double loads directly. Group staging by the natural key and fail on any count above one. This single query would have stopped the incident batch before promotion.

Null-rate checks catch schema drift. When an upstream rename turns customer_id null for 40% of rows, the aggregate flags it in seconds. Each gate is one query, runs in seconds, and saves days.

Steal Soda's double-gate placement even if you don't run Soda. Gate one runs in memory (DuckDB over the inbound batch) before the write — aborts the DAG, stays local to Airflow logs, never spams Slack. Gate two runs against the live table after the write and publishes to Soda Cloud, where stewards, catalogs, and pagers see it. One YAML contract, two enforcement points: pre-prod blocks, production audits. Never publish staging noise to the shared channel or the team mutes quality alerts within a week.

Lean on SodaCL's check vocabulary: freshness (youngest row younger than X), schema (required columns, types), reference (values in table A must exist in table B), plus failed_rows with an expression or query for cross-column rules like ship-date-after-order-date. Give every check both a warn threshold (track the trend) and a fail threshold (stop the run) — warnings for drift you watch, failures for missing keys, invalid statuses, and schema breaks.

sql/quality-gates.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- row-count band: today's batch vs 30-day median volume
WITH history AS (
  SELECT business_date, count(*) AS n
  FROM serving.orders
  WHERE business_date >= current_date - interval '30 days'
  GROUP BY 1
)
SELECT count(*) AS staged_today,
       (SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY n) FROM history) AS median_n
FROM staging.orders
WHERE business_date = '{{ ds }}';

-- dedupe: must return zero rows or the gate fails
SELECT business_date, source_id, count(*)
FROM staging.orders
WHERE business_date = '{{ ds }}'
GROUP BY 1, 2
HAVING count(*) > 1
LIMIT 20;
📊 Production Insight
One GROUP BY query would have stopped the double-load. Gates cost seconds, reconciliations cost days. Rule: dedupe gate on every load, no exceptions.
🎯 Key Takeaway
Count bands catch partial files, dedupe queries catch double loads, null rates catch schema drift. Three queries, seconds each, days saved.

Lineage for Downstream Trust

Lineage answers what-loaded-what when counts look wrong. Record dag_id, run_id, and data_interval with every partition write, and the double-load investigation becomes one lookup instead of a week of archaeology.

You'll store it as partition metadata, not tribal knowledge. A small lineage table with partition, run_id, row count, and loaded_at timestamp serves both debugging and compliance audits. Append on every load; never update in place.

Expose it where analysts look. A dashboard column showing the producing run per partition lets the business self-serve the is-this-fresh question. Fewer pings, faster trust.

📊 Production Insight
Without lineage, what-wrote-this takes days of log archaeology. One metadata row per load fixes that. Rule: every partition records its producing run.
🎯 Key Takeaway
Log run_id per partition on every load and the next double-load diagnosis takes one query. Lineage is debugging infrastructure that doubles as audit evidence.

Data Contracts With Producers

Data contracts turn hallway promises into failing tasks. Producer and consumer agree on volume bands, null ceilings, key uniqueness, and freshness windows, then encode each as a gate both sides can see. Renegotiation happens in the open, not in a postmortem.

You'll version contracts like APIs. A v2 schema with renamed columns ships alongside a v2 gate, and the old gate retires only after consumers migrate. Breaking changes arrive as planned work, not surprises.

Enforce ownership on breach. The contract names the producer contact who gets paged, so a broken extract wakes the team that can fix it. You'll resolve most breaches before analysts finish their coffee.

dags/orders_gated.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import pendulum
from airflow.sdk import dag, task
from airflow.providers.postgres.hooks.postgres import PostgresHook

MEDIAN_TOLERANCE = 0.5  # fail if staged count below 50% of median

@dag(
    dag_id="orders_gated",
    schedule="0 7 * * *",
    start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
    catchup=False,
    tags=["tier-1", "orders"],
)
def orders_gated():
    @task
    def gate_row_count(ds: str | None = None) -> str:
        hook = PostgresHook(postgres_conn_id="analytics_warehouse")
        staged = hook.get_first(
            "SELECT count(*) FROM staging.orders WHERE business_date = %(ds)s",
            parameters={"ds": ds},
        )[0]
        median = hook.get_first(
            "SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY n) FROM "
            "(SELECT count(*) AS n FROM serving.orders "
            "WHERE business_date >= current_date - 30 GROUP BY 1) h",
        )[0] or 0
        if median and staged < median * MEDIAN_TOLERANCE:
            raise ValueError(f"row-count breach: staged={staged} median={median}")
        return f"rows_ok={staged}"

    gate_row_count()

orders_gated()
📊 Production Insight
Handshake agreements break silently; encoded contracts fail loudly. Version them like APIs. Rule: every feed has an owner paged on breach.
🎯 Key Takeaway
Encode volume, null, uniqueness, and freshness promises as versioned gate tasks with named owners. Breaches page producers while batches quarantine safely.

The Failure Story: Double-Loaded Analytics

The double-load played out in slow motion. A manual rerun appended the full batch, counts doubled, and every task glowed green because appending twice is technically success. Analysts found it; monitoring didn't.

Reconciliation took days. Without a dedupe key the team matched rows by timestamps, without lineage they guessed at run boundaries, and without gates there was no quarantine to roll back to. Each missing piece multiplied the cleanup.

The rebuilt pipeline makes repetition safe. Upserts converge, gates quarantine, lineage explains. You'll rerun with confidence now, which is the whole point: operations should be boring.

📊 Production Insight
Success without verification is how doubled data ships. The incident needed three missing pieces at once. Rule: rerun safety is designed, never assumed.
🎯 Key Takeaway
Green tasks loaded bad data twice because nothing checked the result. Upserts plus gates plus lineage turn the next rerun into a non-event.
● Production incidentPOST-MORTEMseverity: high

The Rerun That Loaded Everything Twice

Symptom
Revenue and order counts doubled overnight with every task green. Analysts spotted inflated numbers first; engineering saw a healthy Grid view. Duplicate primary keys appeared across the serving table. Each investigation query took hours because nothing recorded which run had written which rows.
Assumption
The team assumed reruns were rare and careful. Only senior engineers cleared tasks, backfills were planned in chat, and the loader had worked fine for months. Success bred confidence that the happy path was the only path. Nobody designed for the routine double-click.
Root cause
The load task appended rows with no dedupe key, so running it twice meant storing everything twice. No quality gate checked row counts or uniqueness after the load, so the doubled batch promoted straight to serving. With no lineage metadata, the team couldn't quickly tell which run wrote what, stretching a minutes-long diagnosis into days.
Fix
The loader became an upsert keyed on (business_date, source_id) inside a single transaction, so reruns converge instead of duplicating. Row-count band and dedupe checks joined the DAG as gate tasks between staging and promotion, failing loudly on breach. Partition lineage now records run_id per load, and the runbook bans overlapping clears on the same partition.
Key lesson
  • Reruns are normal operations, not edge cases. Design every load to converge under repetition, because retries, clears, and backfills guarantee it.
  • Gates belong before promotion, not after consumption. A check that runs after analysts see the data is a postmortem, not protection.
  • Lineage turns investigations into queries. Recording run_id per partition answers what-loaded-what in seconds instead of days.
Production debug guideFour double-load and bad-batch patterns, with the exact queries that prove each one.4 entries
Symptom · 01
Dashboard counts doubled after a manual rerun
Fix
Count duplicates by the natural key: SELECT business_date, source_id, count() FROM serving.orders GROUP BY 1, 2 HAVING count() > 1 LIMIT 20. If rows appear, the load appended without a dedupe key. Rewrite it as an upsert and re-key the affected partitions.
Symptom · 02
A load succeeded but row counts look wrong
Fix
Compare staging versus history: SELECT count(*) FROM staging.orders WHERE ds='{{ ds }}' against the 30-day median from serving. If the count is off by an order of magnitude, quarantine the batch, fail the gate task, and check the source extract log before promoting.
Symptom · 03
Downstream models degrade after a successful load
Fix
Check null rates on contract columns: SELECT sum(case when customer_id is null then 1 else 0 end)::float / count(*) FROM staging.orders. Over the ceiling means schema drift upstream. Pin the extract to the contract and page the producer team.
Symptom · 04
Two runs wrote the same partition
Fix
Query lineage metadata for the partition: SELECT run_id, loaded_at FROM partition_lineage WHERE partition='{{ ds }}' ORDER BY loaded_at. Two run_ids writing the same partition means overlapping reruns. Serialize clears and backfills per partition going forward. Pull the GX validation result from XCom (ti.xcom_pull) or the Soda failed-rows payload in the gate log — the exact failing values are in there, not just the count.
Data Quality Gates Compared
GateCatchesCost per runFails the DAG?
Row-count band checkEmpty or partial source filesOne COUNT query, secondsYes
Dedupe key checkDouble loads from rerunsOne GROUP BY queryYes
Null-rate checkSchema drift, broken extractsOne aggregate queryYes on breach
Freshness checkStale sources, stuck producersWatermark comparisonYes on breach
Downstream contract testBreaking changes for consumersConsumer query suiteWarn first, then fail
GX operator choiceOver-configured or under-powered validationDataFrame in memory, Batch by definition, Checkpoint for actionstasks
Soda gate placementGating without auditing, or auditing without gatingIn-memory gate pre-write, published check post-writetasks
Freshness / schema / referenceStale, drifted, or orphaned data shipping greenFreshness < interval, schema pinned, references must-existtasks
⚙ Quick Reference
3 commands from this guide
FileCommand / CodePurpose
dagsorders_daily.pyfrom airflow.sdk import dag, taskQuality Checks as First-Class Tasks
sqlquality-gates.sqlWITH history AS (Row-Count and Dedupe Gates
dagsorders_gated.pyfrom airflow.sdk import dag, taskData Contracts With Producers

Key takeaways

1
Make every load idempotent (upsert or INSERT OVERWRITE keyed on the natural key) so a clear-and-rerun converges instead of duplicating.
2
Pick the right GX operator
DataFrame, Batch, or Checkpoint — and fail prod gates hard; warnings alone get ignored most of the time.
3
Run the double gate
in-memory DuckDB check before the write blocks bad rows, production check after the write publishes to Soda Cloud.
4
Cover freshness, schema, and reference checks plus failed_rows expressions for cross-column rules, each with warn and fail thresholds.
5
Map validation tasks over your table list dynamically and push XCom quality metrics to Prometheus so drift pages before users notice.

Common mistakes to avoid

4 patterns
×

Append-only loads with no dedupe key

Symptom
Every manual rerun or backfill doubles rows; analytics counts drift upward with each retry and nobody trusts the dashboard.
Fix
Key every load by (business_date, source_id) or the natural key and use INSERT ... ON CONFLICT DO UPDATE. Reruns then converge instead of duplicating, and backfills become safe by construction. Enforce it in the operator (fail_task_on_validation_failure=True) rather than in a comment — the default must be the safe path.
×

Loading data with no quality gate task

Symptom
A half-empty source file loads 40 rows instead of 4 million; downstream models train on garbage for a week before anyone notices.
Fix
Add row-count and dedupe gate tasks after the load that fail the DAG when counts leave the expected band. Fail loudly at load time instead of discovering drift in a board meeting.
×

Cleaning duplicates with a separate janitor DAG

Symptom
The janitor runs on a different schedule than the loader, so duplicates sit visible for hours and the cleanup itself isn't idempotent.
Fix
Delete by partition or key inside the same transaction as the insert, never with a separate cleanup DAG. The load task owns its rerun safety end to end.
×

Trusting the schedule instead of the data interval

Symptom
A delayed run loads today's partial data under yesterday's partition; the partition looks complete and the gap hides forever.
Fix
Validate data_interval_end and source watermarks inside tasks instead of trusting the schedule. A late-running DAG must load the interval's data, not whatever the source serves today.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Design a rerun-safe load with quality gates. What breaks if you skip eac...
Q02SENIOR
Show me an idempotent upsert pattern for a daily partition.
Q03JUNIOR
What four checks belong in a quality gate?
Q01 of 03SENIOR

Design a rerun-safe load with quality gates. What breaks if you skip each piece?

ANSWER
Idempotent loads keyed on business date plus natural key, so reruns converge via upsert instead of duplicating. Quality gates (row-count bands, dedupe checks) run as tasks between staging and promotion, failing the DAG on breach. Atomic task design means each task owns one verifiable step, and lineage metadata (run_id per partition) makes rerun audits one query. The incident's rerun loaded twice because the load appended with no key and no gate checked the result.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What makes a load task idempotent?
02
Where in the DAG do quality gates belong?
03
Should a failed quality gate fail the DAG or just warn?
04
How does lineage help during a double-load incident?
05
What is a data contract with producers?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
September 04, 2026
last updated
1,750
articles · all by Naren
🔥

That's Airflow. Mark it forged?

3 min read · try the examples if you haven't

Previous
Airflow Security RBAC and Secrets
32 / 37 · Airflow
Next
Airflow Deferrable Operators and Triggerer