Home DevOps Airflow Data Quality: The Rerun That Loaded Twice!
Advanced 4 min · August 1, 2026

Airflow Data Quality: The Rerun That Loaded Twice!

Airflow data quality gates stop double loads: idempotent upserts, row-count checks, and dedupe gates.

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
August 01, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 15-20 min read
  • Experience writing DAGs with load tasks.
  • A Postgres (or similar) data warehouse for the upsert examples.
  • Understanding of task dependencies and the graph view.
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Idempotency is the rerun safety net: the same DAG run can execute twice with identical results.
  • Upserts by business key or partition replace append-only loads that duplicate rows.
  • Quality checks are first-class tasks: row-count parity, dedupe, and null-rate gates between load and consumers.
  • A manual rerun double-loaded our analytics table because no dedupe key and no gate existed.
  • Place gates before consumers: a failed check must stop the DAG, not just log a warning.
  • Lineage and data contracts make quality everyone's problem, not just the pipeline owner's.
✦ Definition~90s read
What is Airflow Data Quality?

Airflow data quality is the practice of making loads idempotent and placing row-count, dedupe, and contract checks as first-class tasks so bad data stops before consumers see it.

Think of a delivery van that unloads boxes onto the same shelf every morning.
Plain-English First

Think of a delivery van that unloads boxes onto the same shelf every morning. If a driver gets a second order to make the same delivery, the shelf now holds two copies of every box. A quality gate is a stock check: it counts the boxes before unloading and after, and refuses the second delivery if the shelf already has them. Idempotency is a smarter shelf: it swaps out old boxes instead of stacking new ones.

Every Airflow load task has a rerun problem. The DAG fails, you clear it, it runs again — and if the load appends instead of upserts, the table now holds two copies of everything. This is the most common data-quality failure in production, and it's almost never caught by the DAG's own success status.

Our version: an analytics table got double-loaded by a manual rerun. Nothing failed. The DAG was green, the checks were 'fine', and the dashboards quietly showed every number twice.

The fix is boring and permanent: idempotent loads, quality checks as real tasks, and gates placed before consumers can touch the data.

A green DAG is a promise that the work ran. It says nothing about the data.

1. Idempotency: The Rerun Safety Net

Idempotency means running the same operation twice produces the same end state as running it once. For loads, that's an upsert by business key or a write into a partition boundary that gets replaced.

Airflow reruns are a fact of life: clears, backfills, catchups, manual triggers. Every one of them is safe when the load is idempotent and dangerous when it isn't.

Our load was INSERT INTO analytics.orders SELECT ... FROM staging.orders. Pure append. The second run didn't fail — it duplicated.

An append-only load is a promise that you'll never rerun. You will rerun.

load_orders.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
UPSERT_ORDERS_SQL = """
INSERT INTO analytics.orders (order_id, customer_id, total, loaded_at)
SELECT order_id, customer_id, total, %(run_ts)s
FROM staging.orders
ON CONFLICT (order_id)
DO UPDATE SET customer_id = EXCLUDED.customer_id,
              total = EXCLUDED.total,
              loaded_at = EXCLUDED.loaded_at;
"""

load_orders = PostgresOperator(
    task_id="load_orders_upsert",
    postgres_conn_id="analytics_prod",
    sql=UPSERT_ORDERS_SQL,
    parameters={"run_ts": "{{ data_interval_end }}"},
)
Output
INSERT 0 48211
📊 Production Insight
Reruns are guaranteed; duplicate loads are optional.
Upsert by key turns reruns into no-ops.
Rule: no load task ships without an idempotency story.
🎯 Key Takeaway
Idempotent means second run equals first run.
Append-only loads fail the rerun test by design.
Rule: upsert by business key or write to replaceable partitions.
Idempotent Loads vs Append-Only Idempotent Loads vs Append-Only Rerun safety net — append vs upsert Append-only INSERT Upsert by business key Run 1 inserts 48,211 rows inserts 48,211 rows Run 2 — rerun inserts 48,211 again ON CONFLICT — update End state 96,422 rows — doubled 48,211 rows — unchanged Clears · backfills · manual triggers idempotent loads make reruns safe THECODEFORGE.IO
thecodeforge.io
Airflow Data Quality

2. Quality Checks as First-Class Tasks

A quality check is not a dashboard or a notebook cell. It's a task in the DAG with a dependency edge, and when it fails, the DAG fails.

Row-count parity between staging and target catches lost and duplicate batches. Null-rate checks catch broken transforms. Freshness checks catch stale partitions.

Each check is a task that raises on violation. No raise, no failure — and a check that only logs is a lie wearing a green icon.

The graph view should show it: load, then gate, then consumers. The gate is not optional; it's the reason the DAG exists.

You don't have to hand-roll every gate: the common SQL provider ships six ready-made check operatorsSQLCheckOperator, SQLColumnCheckOperator, SQLTableCheckOperator, SQLIntervalCheckOperator, SQLValueCheckOperator, and SQLThresholdCheckOperator. SQLCheckOperator runs any query and fails the task when a returned value is falsy in PythonSELECT COUNT(*) FROM foo fails only when the count is 0. SQLColumnCheckOperator runs predefined column checks (null_check, min, max, distinct_check) from a column_mapping dict, with partition_clause to scope rows and tolerance for fuzzy bounds. Astronomer recommends the column and table operators over the older value and threshold variants.

quality_gates.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
def verify_row_parity(**context):
    staging = count_rows("staging.orders", conn="analytics_prod")
    target = count_rows("analytics.orders", conn="analytics_prod")
    if staging != target:
        raise AirflowException(
            f"Row-count mismatch: staging={staging}, target={target}"
        )


verify_parity = PythonOperator(
    task_id="verify_row_parity",
    python_callable=verify_row_parity,
)
Output
AirflowException: Row-count mismatch: staging=48211, target=96422
Mental Model
The Gate Is a Tripwire, Not a Report
A check that doesn't fail the DAG is a suggestion.
  • A report you read on Monday describes Friday's failure.
  • A tripwire stops consumers before they touch the data.
  • Placement and failure behavior are the whole design.
📊 Production Insight
A check that can't fail the DAG is a suggestion.
Parity and null-rate gates catch the double load.
Rule: every gate raises, and raising stops the pipeline.
🎯 Key Takeaway
Checks are tasks with dependency edges.
Log-only checks are lies with green icons.
Rule: gates fail loudly, between load and consumers.
Quality Checks Are DAG Tasks Quality Checks Are DAG Tasks Gates fail the DAG, not just log Load task — idempotent upsert by business key Row-count parity staging vs target — raises on fail — Null-rate check broken transforms — raises on fail — Freshness check stale partitions — raises on fail — Consumers run verified data only DAG fails check raises on violation A check that only logs is a lie wearing a green icon THECODEFORGE.IO
thecodeforge.io
Airflow Data Quality

3. Row-Count and Dedupe Gates

Two gates catch the double-load class of bug specifically: the dedupe gate and the parity gate.

The dedupe gate runs before the load and after it: group the source by business key and fail if any key appears twice. The parity gate compares source and target counts after the load.

Neither is expensive. Both are one SQL statement each, run as tasks, and they turn the rerun failure from a silent double into a loud, green-checked DAG that stops itself.

Double-loaded analytics is exactly what these two gates exist to prevent.

SQLTableCheckOperator is the natural home for the parity rule: pass checks like {"row_count_check": {"check_statement": "COUNT(*) >= 3"}} and the operator fails when any statement evaluates false. For drift that isn't duplication — values slowly creeping out of range — SQLIntervalCheckOperator compares today's metric against the same metric from days ago, catching slow decay that a hard parity gate misses.

dedupe_check.sqlSQL
1
2
3
4
5
SELECT order_id, COUNT(*) AS copies
FROM staging.orders
GROUP BY order_id
HAVING COUNT(*) > 1
LIMIT 10;
Output
order_id | copies
5021938 | 2
7781201 | 2
📊 Production Insight
Dedupe before load, parity after load.
Two SQL statements close the double-load class.
Rule: run both gates on every production load.
🎯 Key Takeaway
Dedupe gates catch the source; parity gates catch the load.
Both fail the DAG, so neither can be ignored.
Rule: gates are not optional decorations.
The Two Double-Load Gates The Two Double-Load Gates Dedupe before load, parity after Dedupe gate — before the load group source by business key Load task upsert — ON CONFLICT (order_id) Parity gate — after the load staging vs target row counts Consumers run only on verified data Gate fired: staging=48,211 target=96,422 — mismatch raised THECODEFORGE.IO
thecodeforge.io
Airflow Data Quality

4. Lineage for Downstream Trust

Lineage answers the question users actually ask: where did this number come from? Airflow shows task and DAG relationships in the grid view; Airflow 3's assets make the producer-consumer links explicit.

When a downstream team sees a warning on their dashboard, lineage tells them which DAG fed it, which task wrote it, and which gate checked it. That turns 'your data is wrong' into 'the orders DAG failed the parity gate'.

Lineage is also the trust that makes quality gates acceptable to consumers: they can see the gates exist.

A table without lineage is a rumor. A table with lineage is an argument.

📊 Production Insight
Lineage turns data complaints into pointer arrows.
Visible gates make consumers trust the load.
Rule: every table knows its producing DAG and task.
🎯 Key Takeaway
Lineage is the answer to 'where did this come from?'.
Grid view and assets make it explicit.
Rule: document upstream and downstream for every table.

5. Data Contracts with Producers

A data contract is the written agreement between the producer of a table and its consumers: schema, key, freshness, volume range. When the producer breaks the contract, the pipeline should fail at ingest — not silently corrupt downstream.

A validation task at the ingest step checks the shape of the incoming batch: required columns, unique keys, null rates within bounds.

Contracts turn quality from a pipeline owner's hobby into a team standard. The producer can't change a column and hope; the contract check catches it.

No contract means every consumer re-verifies. That's the tax you stop paying.

📊 Production Insight
Contracts move quality upstream to the producer.
Ingest validation catches drift at the door.
Rule: every source table ships with a checked contract.
🎯 Key Takeaway
A contract is a written, enforced agreement.
Ingest validation makes it real, not aspirational.
Rule: schema, key, freshness, volume — checked every run.

6. The Failure Story: Double-Loaded Analytics

Monday, 09:12 — an engineer cleared the orders load because a transient DB blip failed the last task. The rerun went green. Nothing complained.

Tuesday, 10:00 — the finance dashboard showed revenue at exactly double the previous day's. The team's first instinct was a query bug. It wasn't.

The load was INSERT ... SELECT with no dedupe key. The first run had committed 48,211 rows before failing on a later task; the clear re-ran the whole DAG, and the load inserted the same 48,211 rows again.

Two facts made this inevitable: append-only load, and no gate between load and consumers. One upsert would have made the rerun harmless. One parity gate would have made it loud.

It was a Tuesday afternoon to fix. It took a Monday morning to notice.

⚠ Green Is Not Correct
The DAG's success state proves the tasks ran. It proves nothing about the rows they wrote. Every green run is a hypothesis until a gate verifies it.
📊 Production Insight
The rerun was harmless-looking and catastrophic.
Two controls would have prevented it entirely.
Rule: idempotency plus gates is the whole lesson.
🎯 Key Takeaway
Append plus rerun equals double load.
Green status never validated a single row.
Rule: reruns must be harmless by design, always.
Where Do Your Checks Belong?
IfThe load appends with no key
UseRewrite as an upsert by business key before adding any checks.
IfThe table has no unique key
UseAdd a surrogate key or partition boundary; dedupe gates alone won't hold.
IfA rerun may insert the same rows again
UseDedupe gate before load; parity gate after load.
IfConsumers run right after the load
UseInsert the gates as tasks between load and consumers.
IfProducer schema changes without notice
UseAdd a contract validation task at the ingest step.
IfGates keep failing on known-bad data
UseFix the producer upstream; never skip the gate.

7. The Quality-First DAG Pattern

The shape is always the same: extract, load idempotently, gate, then let consumers run. Extract and load stay atomic; gates sit between load and the rest of the world.

This pattern makes clears safe, backfills safe, and Monday-morning fire drills unnecessary. The DAG is a machine that either produces verified data or stops itself.

Ship the gates with the load — they're not a phase-two initiative. The day they ship is the day reruns stop being dangerous.

One deliberate staging exception: a soft gate. Airflow fails a run only when a leaf task ends failed, so a leaf with trigger_rule="all_done" runs regardless of upstream check results — the run stays green while on_failure_callback still fires. Use it where you want a visible warning, not a stop; production gates stay hard.

orders_quality_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
with DAG(
    dag_id="orders_quality_gated",
    schedule="@daily",
    catchup=False,
    default_args=default_args,
) as dag:
    extract = PythonOperator(
        task_id="extract_orders", python_callable=extract_orders
    )
    load = PostgresOperator(
        task_id="load_orders_upsert",
        postgres_conn_id="analytics_prod",
        sql=UPSERT_ORDERS_SQL,
    )
    check_dedup = PostgresOperator(
        task_id="check_no_duplicates",
        postgres_conn_id="analytics_prod",
        sql=DEDUPE_CHECK_SQL,
    )
    check_parity = PythonOperator(
        task_id="verify_row_parity", python_callable=verify_row_parity
    )

    extract >> load >> check_dedup >> check_parity
Output
DAG orders_quality_gated: 4 tasks, gates between load and consumers
📊 Production Insight
One shape covers every quality concern.
Extract, idempotent load, gates, consumers.
Rule: gates ship with the load, not in phase two.
🎯 Key Takeaway
Atomic load plus gates equals safe reruns.
The pattern is identical for every pipeline.
Rule: quality is built into the DAG, not bolted on.
● Production incidentPOST-MORTEMseverity: high

The Rerun That Loaded Twice

Symptom
KPIs and row counts in downstream dashboards doubled overnight after an engineer cleared and re-ran the load DAG manually.
Assumption
The team assumed the load task was idempotent because the DAG had run successfully twice — a green run was treated as proof the data was correct.
Root cause
The load was append-only with no dedupe key or partition boundary; a manual rerun inserted the same rows again, and no quality gate (row-count parity, dedupe check) existed between the load and the consumers.
Fix
Rewrote the load as an idempotent upsert by business key, added row-count and dedupe gates as tasks that fail the DAG on violation, and documented the clear-and-rerun procedure.
Key lesson
  • A green DAG run proves the tasks executed, not that the data is correct.
  • Append-only loads guarantee duplication on any rerun.
  • Quality checks belong in the DAG as failing tasks, not in a notebook after the fact.
  • Manual reruns are the highest-risk operation a pipeline has — make them harmless by design.
Production debug guideSymptom to Action5 entries
Symptom · 01
Dashboard numbers doubled overnight
Fix
Run a dedupe query by business key on the target table; the counts will show every row twice.
Symptom · 02
Reruns re-insert the same rows
Fix
Check the load SQL for an upsert or ON CONFLICT clause; append-only loads guarantee duplication.
Symptom · 03
Row counts differ between staging and target
Fix
Add a parity gate task after the load; mismatch means a lost or duplicate batch.
Symptom · 04
Quality check task exists but the DAG stays green
Fix
The check is probably logging instead of raising — make violations raise and fail the DAG.
Symptom · 05
Consumers read data before quality checks run
Fix
Rewire dependencies so gates sit between load and consumers in the graph.
Quality Check Placement Options
PlacementCatchesCostFailure behavior
Inside the load SQL (upsert)Duplicates at write timeLowAtomic — nothing bad is written
Dedupe gate before loadDuplicate rows in the source batchLow-mediumDAG fails before any write
Parity gate after loadLost or duplicated batchesLowDAG fails before consumers
Contract check at ingestSchema and freshness driftMediumDAG fails at the producer boundary
External monitoring toolSlow drift over timeHighAlerts outside the DAG
No checksNothingZeroSilent corruption until users notice
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
load_orders.pyUPSERT_ORDERS_SQL = """1. Idempotency
quality_gates.pydef verify_row_parity(**context):2. Quality Checks as First-Class Tasks
dedupe_check.sqlSELECT order_id, COUNT(*) AS copies3. Row-Count and Dedupe Gates
orders_quality_gated.pywith DAG(7. The Quality-First DAG Pattern

Key takeaways

1
Idempotent loads
upsert by business key or replaceable partitions — make reruns harmless by design.
2
Quality checks are first-class DAG tasks that raise on violation; log-only checks are lies.
3
Dedupe gates run before the load, parity gates after
two SQL statements close the double-load class.
4
Lineage and data contracts move quality from one owner's hobby to a team-wide, enforced standard.
5
A green DAG proves the tasks ran, never that the data is correct
gates are the verification.

Common mistakes to avoid

4 patterns
×

Append-only loads with no dedupe key

Symptom
Every rerun duplicates rows and totals double
Fix
Upsert by business key or write into a replaceable partition boundary.
×

Quality checks that log instead of failing

Symptom
The gate task is green while the data is wrong
Fix
Raise an exception on violation so the check actually fails the DAG.
×

Gates placed after consumers

Symptom
Bad data reaches dashboards before the check runs
Fix
Put gates between load and consumers in the dependency graph.
×

Clearing and re-running without a procedure

Symptom
Manual reruns double-load the table
Fix
Document the clear-and-rerun procedure and design loads so reruns are harmless.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What makes a load task idempotent?
Q02SENIOR
Design a quality gate for a daily order load that has been double-loadin...
Q03SENIOR
How do you make data quality enforceable across teams with producers and...
Q01 of 03JUNIOR

What makes a load task idempotent?

ANSWER
Running it twice produces the same end state as running it once. Practically: an upsert by business key (ON CONFLICT) or a write into a partition boundary that gets replaced. An append-only INSERT is not idempotent and duplicates rows on any rerun.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What does idempotent mean for Airflow tasks?
02
How do I write an upsert in Postgres?
03
Should quality checks fail the DAG or just alert?
04
How do I check for duplicate rows?
05
Are there built-in operators for data quality checks?
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
August 01, 2026
last updated
1,750
articles · all by Naren
🔥

That's Airflow. Mark it forged?

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

Previous
Airflow Security
32 / 37 · Airflow
Next
Airflow Deferrable Operators