1. DAGs, Tasks & the Scheduler Model
An Airflow DAG (Directed Acyclic Graph) is a Python file that declares a set of tasks and the dependencies between them — which tasks must finish before others can start. "Acyclic" is a real constraint, not just terminology: a DAG can never loop back on itself, because Airflow needs to compute a definite execution order, and a cycle would mean two tasks each waiting on the other forever.
from airflow.decorators import dag, task
from datetime import datetime, timedelta
@dag(
schedule="0 2 * * *", # 02:00 UTC daily
start_date=datetime(2026, 1, 1),
catchup=False,
default_args={"retries": 2, "retry_delay": timedelta(minutes=5)},
)
def daily_orders_report():
@task
def extract_orders() -> str:
# pulls yesterday's orders from the production database
return export_orders_to_parquet()
@task
def transform_orders(raw_path: str) -> str:
return aggregate_daily_totals(raw_path)
@task
def load_to_warehouse(aggregated_path: str) -> None:
load_into_warehouse_table(aggregated_path)
load_to_warehouse(transform_orders(extract_orders()))
daily_orders_report()
Calling transform_orders(extract_orders()) does two things at once: it
declares the dependency (transform_orders can't run until
extract_orders finishes) and wires extract_orders's return
value into transform_orders's argument, via Airflow's
XCom mechanism — a small key-value store the scheduler uses to pass
data between tasks. This is the same explicit-dependency idea as Week 18's saga
steps, but for a scheduled batch pipeline instead of an event-driven one: each task
only needs to know what it depends on, and the scheduler assembles the full execution
order from those declarations.
XCom stores its values in Airflow's own metadata database, which is fine for a file path, an ID, or a small summary — but passing a large DataFrame or file contents through XCom directly can overwhelm that database. The pattern above passes a path to data written to shared storage (S3, a mounted volume), not the data itself, which is the standard way to move real payloads between Airflow tasks.
2. A Real ETL Pipeline
Fleshing out Section 1's skeleton with real logic: Extract pulls data from a source system into a raw, intermediate form; Transform cleans, validates, and aggregates it; Load writes the result to its final destination — a pattern that generalizes to almost any pipeline regardless of the specific data involved.
import pandas as pd
from sqlalchemy import create_engine
def export_orders_to_parquet() -> str:
engine = create_engine(PROD_DB_URL)
df = pd.read_sql(
"SELECT * FROM orders WHERE created_at >= now() - interval '1 day'",
engine,
)
path = f"/data/raw/orders_{datetime.now():%Y%m%d}.parquet"
df.to_parquet(path)
return path
def aggregate_daily_totals(raw_path: str) -> str:
df = pd.read_parquet(raw_path)
daily = df.groupby(df["created_at"].dt.date).agg(
total_orders=("id", "count"),
total_revenue=("amount", "sum"),
)
out_path = raw_path.replace("raw", "aggregated")
daily.to_parquet(out_path)
return out_path
def load_into_warehouse_table(aggregated_path: str) -> None:
df = pd.read_parquet(aggregated_path)
engine = create_engine(WAREHOUSE_DB_URL)
df.to_sql("daily_order_summary", engine, if_exists="append", index=True)
Notice the production database (PROD_DB_URL) and the warehouse
(WAREHOUSE_DB_URL) are deliberately separate — the extract step reads
from the same database your FastAPI service writes to, but the aggregation and load
steps run against a warehouse built for analytical queries, so a heavy daily
aggregation never competes with your API's live traffic for the same database's
resources.
Airflow retries failed tasks, and you'll manually rerun tasks during development and backfills constantly. A load_into_warehouse_table using if_exists="append" would insert duplicate rows on a rerun for the same day — a real production pipeline needs a delete-then-insert or upsert step keyed on the date, so running the same task twice for the same day produces the same result as running it once.
3. Retries, Backfills & Monitoring a Production DAG
default_args={"retries": 2, "retry_delay": timedelta(minutes=5)} from
Section 1 means a failed task — a transient network blip pulling from the source
database, say — is retried automatically before the whole DAG is marked failed,
the same instinct behind Week 9's retry patterns for HTTP calls, applied to a
scheduled pipeline instead of a live request.
A backfill runs a DAG for a range of past dates it either never ran for, or needs to rerun after a bug fix:
airflow dags backfill daily_orders_report \
--start-date 2026-07-01 \
--end-date 2026-07-14
This is exactly why every task needs to be idempotent per the previous callout — a backfill reruns each day's tasks, potentially including days that already succeeded once before, and it should be safe to do so without producing duplicate or inconsistent data in the warehouse.
The Airflow web UI's Grid view shows every DAG run and every task's status (success, failed, retried, running) at a glance, which is the first place to look when a pipeline needs attention — but for genuine production monitoring, wire Airflow's task failure callbacks into the same alerting channel from Week 12's observability practices, rather than relying on someone remembering to check the UI:
def notify_failure(context):
task_id = context["task_instance"].task_id
dag_id = context["dag"].dag_id
send_alert(f"Airflow task failed: {dag_id}.{task_id}")
@dag(
schedule="0 2 * * *",
default_args={
"retries": 2,
"retry_delay": timedelta(minutes=5),
"on_failure_callback": notify_failure,
},
)
def daily_orders_report():
...
An API error is visible immediately — a user hits it, a request fails, something pages. A batch pipeline that quietly stops producing fresh data can go unnoticed for days, since nothing in the live system fails loudly; the dashboard just gets stale. Alerting on task failure is what turns a silent staleness problem into an immediately visible one.
4. Hands-on Exercise
Build a real, idempotent ETL DAG and prove its retry and backfill behavior
Stand up Airflow locally and build a pipeline against your own service's data.
Requirements:
- Run Airflow locally (the official Docker Compose setup is the fastest path) and build an Extract-Transform-Load DAG against your task service's database, aggregating some daily metric.
- Make every task idempotent — running the DAG twice for the same date should produce the same warehouse state as running it once, not duplicate rows.
- Deliberately make one task fail (a bad connection string, a bug) and confirm the configured retry policy kicks in before the DAG is marked failed.
- Run a backfill for a range of past dates and confirm each day's data lands correctly in the warehouse table.
- Add an
on_failure_callbackthat logs or notifies on task failure, and confirm it fires when you force a task to fail.
catchup=False in the DAG definition is important during development — without it, Airflow will try to run every scheduled interval between your start_date and now the moment you turn the DAG on, which can mean dozens of unexpected runs firing at once.
5. Knowledge Check
Three quick questions. Expand each to check your answer.
Q1
Why must an Airflow DAG be acyclic — why can't two tasks depend on each other?
Why must an Airflow DAG be acyclic — why can't two tasks depend on each other?
The scheduler needs to compute a definite execution order from the declared dependencies before running anything. A cycle — task A depending on task B, which depends back on task A — has no valid starting point; neither task could ever be the "first" one to run, since each is waiting on the other, so Airflow simply disallows this shape entirely.
Q2
Why does a task passing a file path through XCom, rather than the file's actual contents, matter for a real pipeline?
Why does a task passing a file path through XCom, rather than the file's actual contents, matter for a real pipeline?
XCom values are stored in Airflow's own metadata database, which is designed for small values like IDs and paths, not large payloads. Passing an entire DataFrame or file's contents through XCom can overwhelm that database; writing the data to shared storage and passing only its location keeps XCom usage small while still letting downstream tasks locate and read the actual data.
Q3
Why does a backfill specifically require every task in the DAG to be idempotent?
Why does a backfill specifically require every task in the DAG to be idempotent?
A backfill reruns a DAG's tasks for a range of dates, which can include dates that already ran and succeeded once before — for example, after fixing a bug and wanting to regenerate correct data for days that ran with the old, buggy logic. If a task's load step simply appends rows rather than overwriting or upserting them, rerunning it for an already-processed date produces duplicate data instead of correcting it.