Why pipelines must be idempotent — and how to make them so.
Data Engineering Module 2 OrchestrationIn the Lab 05 bonus step, re-running an interval overwrote the Parquet file. Looks fine — right? What if the second run failed halfway through the write? Now you have a half-written file: corrupt, but named like a complete one. The filesystem shows it exists; downstream reads return garbage.
Idempotency means: running the same task with the same inputs N times produces the same output as running it once. The orchestrator gives you at-least-once execution. Only the task itself can give you exactly-once.
Every task should expect to run more than once. Design for the second run from the start.
Easy guarantee from the orchestrator. Just retry on failure. Airflow gives you this out of the box with retries > 0.
Downside: the task may run 2, 3, or more times.
Hard guarantee — requires the task itself to be idempotent. Airflow can't give you this; you build it.
Result: N runs, same final state as 1 run.
import os
from pathlib import Path
def transactional_write(df, final_path: str):
staging = f"{final_path}.staging.tmp"
df.to_parquet(staging) # write to staging
os.replace(staging, final_path) # atomic rename — POSIX guarantees
os.replace() is atomic within a single filesystem — POSIX standardWhen you can't atomically replace a single file (e.g., appending to a partitioned table), encode the logical date into the output path. Re-runs overwrite the same partition. Other partitions stay untouched.
# Partition by hour — replay overwrites only this hour's partition
output = f"data/event_date={date}/hour={hour}/events.parquet"
df.to_parquet(output) # second run for same (date, hour) is safe
This is the lakehouse pattern — Module 4 builds on it with Iceberg and Delta.
def _extract(**context):
from extract.gharchive import fetch_hour
path = fetch_hour(hour=_hour_key(context))
return path # automatically pushed to XCom
def _transform(**context):
from transform.events import count_by_type
path = context["task_instance"].xcom_pull(task_ids="extract")
count_by_type(input_path=path, output_path=f"data/out/{_hour_key(context)}.csv")
XCom is metadata-only — small JSON-serializable values (paths, counts, IDs). Not for DataFrames.
Small metadata between tasks: a path, a count, a record ID. Backed by the Airflow metadata DB. Visible in the UI.
Limit: keep payloads < a few KB. The metadata DB will choke on large payloads.
Write to S3 / Parquet / warehouse; let downstream read by convention (same partition path). For big data, the side-effect is the contract. XCom carries the address, not the data.
from datetime import timedelta
from airflow.operators.python import PythonOperator
extract = PythonOperator(
task_id="extract",
python_callable=_extract,
retries=3,
retry_delay=timedelta(seconds=30),
retry_exponential_backoff=True,
max_retry_delay=timedelta(minutes=10),
)
Exponential backoff schedule: 30 s → 60 s → 120 s → capped at 10 min. After exhausting retries, task lands in failed and the DAG run is marked failed.
Don't set retries=100. A task that fails 100 times in 100 minutes is
broken, not flaky. Retries are for transient failures, not real bugs.
retries=3 with exponential backoffretries=0 — failure is a real bug, surface it fastmode='a') — replay doubles the rows.
Fix: always overwrite; use staging-then-rename.
UPDATE SET count = count + 1
— replay double-counts. Fix: upsert with ON CONFLICT DO UPDATE SET count = EXCLUDED.count.
uuid4() as the ID)
— retry creates a duplicate order. Fix: derive the idempotency key from the run_key, not a random UUID.
Three changes to dags/gharchive_hourly.py:
data/<hour>.parquet.staging.<run_id>.parquet, then
os.replace() to data/<hour>.parquet. Transform does the same
for data/out/<hour>.csv.
EXTERNAL_API_RETRIES (3 retries, exponential backoff); transform uses
PURE_COMPUTE_RETRIES (0 retries).
xcom_pull instead of reconstructing it.
All presets live in a new dags/_helpers.py shared by all Module 2 DAGs.
CopyObject to the final key from the
staging key.
ON CONFLICT — second run fails on PK violation,
which Airflow retries indefinitely, masking the real bug. Write upserts, not inserts.