Week 20: Data Pipelines & Batch Processing with Airflow

Week 14's BackgroundTasks and Celery handle work measured in seconds, triggered by a request. Some work doesn't fit that shape at all — a nightly import of a partner's data feed, a daily aggregation job, a report that depends on three upstream data sources finishing in order. Apache Airflow is the standard tool for exactly that: scheduled, dependency-aware, observable data pipelines, independent of any single API request.

Module 17 of 22 Week 20 of 26 ~4–5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Explain DAGs, tasks, and how Airflow's scheduler decides what runs when
  • Build a real Extract-Transform-Load pipeline with dependent tasks
  • Configure retries, backfills, and monitor a DAG in production

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.

dags/daily_orders_report.py
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 is for small values, not large payloads

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.

pipeline_tasks.py — the actual work each task calls
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.

Every task should be idempotent — safe to rerun for the same date

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:

terminal — backfilling two weeks of missed runs
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:

alerting on task failure
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():
    ...
A silently failing nightly pipeline is worse than an API that returns a 500

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

Hands-on

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:

  1. 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.
  2. 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.
  3. 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.
  4. Run a backfill for a range of past dates and confirm each day's data lands correctly in the warehouse table.
  5. Add an on_failure_callback that logs or notifies on task failure, and confirm it fires when you force a task to fail.
Hint

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?

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?

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?

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.