The earlier articles covered each Airflow concept in isolation. This one builds one realistic DAG that uses all of them together, explaining each decision, with the complete file at the end.
The pipeline: every hour, load new order events into staging.orders, rebuild
three marts from staging, roll the marts up over the last 30 days, check the
result, and alert if anything failed.
The shape#
read_watermark |load_raw_to_staging | +--> build_customer_metrics --+ +--> build_daily_revenue -----+--> rollup_last_30_days --> check_rollup +--> build_segment_revenue ---+
(on any failure) alert_on_failureread_watermarkfinds where the last run stopped and passes it forward (XCom).load_raw_to_stagingloads only the current interval's rows, idempotently.- The three mart tasks are independent, so they run in parallel.
rollup_last_30_dayswaits for all three.check_rollupis a data-quality gate.alert_on_failureis a DAG-level failure callback, not a task.
The callables live outside the DAG#
Every unit of real work is a plain function in its own module — no Airflow imports, independently testable. The DAG file only wires them together.
# common/clickhouse.pyfrom airflow.hooks.base import BaseHookfrom clickhouse_connect import get_client
def clickhouse_client(): conn = BaseHook.get_connection("clickhouse_default") return get_client( host=conn.host, port=conn.port, username=conn.login, password=conn.password, )Credentials come from the clickhouse_default
Connection, not from the DAG.
Task 1: read the watermark#
Find the newest created_at already in staging. Returning the value pushes it
to XCom:
# transform/watermark.pyfrom common.clickhouse import clickhouse_client
def read_staging_watermark(**context): client = clickhouse_client() result = client.query("SELECT maxOrNull(created_at) FROM staging.orders") watermark = result.result_columns[0][0] print(f"staging watermark: {watermark}") return watermark # -> XCom "return_value"A watermark is a single timestamp — small, so XCom is the right channel. A full dataset would not be.
Task 2: interval-aware, idempotent load#
Load rows for this run's interval, and make the write safe to repeat:
# transform/raw_to_staging.pyfrom common.clickhouse import clickhouse_client
def load_raw_to_staging(**context): start = context["data_interval_start"] end = context["data_interval_end"] client = clickhouse_client()
# idempotent: clear this window first, then insert it client.command( "ALTER TABLE staging.orders DELETE WHERE created_at >= {s:DateTime} AND created_at < {e:DateTime}", parameters={"s": start, "e": end}, ) client.command( """ INSERT INTO staging.orders SELECT order_id, customer_id, created_at, amount, status FROM raw.orders WHERE created_at >= {s:DateTime} AND created_at < {e:DateTime} """, parameters={"s": start, "e": end}, )Why this shape:
data_interval_start/data_interval_end, notdatetime.now(), so a late run or a backfill processes the correct window (Scheduling and Backfill).- Delete-then-insert the window, so a retry or a backfill overwrites instead of duplicating (Building Staging and Data Marts).
- The
watermarkfrom task 1 is available viacontext["ti"].xcom_pull(task_ids="read_watermark")if the load wants a belt-and-braces lower bound; the interval is the primary boundary.
Task 3: three marts in parallel#
Each mart is its own function and its own task. They do not depend on each other, so Airflow runs them concurrently:
# marts/build.pyfrom common.clickhouse import clickhouse_client
def _rebuild_day(table, select_sql, day): client = clickhouse_client() # table names are identifiers (built into the string); day is a value (bound) client.command( f"ALTER TABLE {table} DROP PARTITION " + "{day:String}", parameters={"day": day}, ) client.command(f"INSERT INTO {table} {select_sql}")
def build_customer_metrics(**context): day = context["ds"] _rebuild_day("mart.customer_metrics", "SELECT ... FROM staging.orders WHERE ...", day)
def build_daily_revenue(**context): day = context["ds"] _rebuild_day("mart.daily_revenue", "SELECT ... FROM staging.orders WHERE ...", day)
def build_segment_revenue(**context): day = context["ds"] _rebuild_day( "mart.segment_revenue", "SELECT ... FROM staging.orders o LEFT JOIN staging.customers c ON ...", day, )Same idempotency pattern: drop the day's partition, then insert it.
Task 4: the rollup#
Runs once, after all three marts, over a longer window:
# marts/rollup.pyfrom common.clickhouse import clickhouse_client
def rollup_last_30_days(**context): end = context["data_interval_end"] client = clickhouse_client() client.command( """ INSERT INTO mart.customer_30d SELECT customer_id, sum(order_count), sum(total_amount) FROM mart.customer_metrics WHERE day >= {e:DateTime} - INTERVAL 30 DAY GROUP BY customer_id """, parameters={"e": end}, )Task 5: the quality gate#
A task that raises if the output is wrong, so the run fails loudly instead of publishing bad data:
# checks/rollup.pyfrom common.clickhouse import clickhouse_client
def check_rollup(**context): client = clickhouse_client() rows = client.query("SELECT count() FROM mart.customer_30d").result_columns[0][0] if rows == 0: raise ValueError("mart.customer_30d is empty after the rollup")The DAG file#
Everything above is plain functions. The DAG file imports them and declares the graph:
# dags/orders_pipeline.pyfrom datetime import datetime, timedelta
from airflow import DAGfrom airflow.operators.python import PythonOperator
from transform.watermark import read_staging_watermarkfrom transform.raw_to_staging import load_raw_to_stagingfrom marts.build import build_customer_metrics, build_daily_revenue, build_segment_revenuefrom marts.rollup import rollup_last_30_daysfrom checks.rollup import check_rollupfrom common.alerts import alert_on_failure
default_args = { "owner": "data-team", "retries": 2, "retry_delay": timedelta(minutes=5), "on_failure_callback": alert_on_failure,}
with DAG( dag_id="orders_pipeline", default_args=default_args, description="Hourly: raw -> staging -> marts -> 30d rollup", start_date=datetime(2026, 1, 1), schedule="@hourly", catchup=False, max_active_runs=1, tags=["orders", "etl"],) as dag:
read_watermark = PythonOperator( task_id="read_watermark", python_callable=read_staging_watermark, )
load_staging = PythonOperator( task_id="load_raw_to_staging", python_callable=load_raw_to_staging, )
marts = [ PythonOperator(task_id="build_customer_metrics", python_callable=build_customer_metrics), PythonOperator(task_id="build_daily_revenue", python_callable=build_daily_revenue), PythonOperator(task_id="build_segment_revenue", python_callable=build_segment_revenue), ]
rollup = PythonOperator( task_id="rollup_last_30_days", python_callable=rollup_last_30_days, )
check = PythonOperator( task_id="check_rollup", python_callable=check_rollup, )
read_watermark >> load_staging >> marts >> rollup >> checkThe last line is the whole structure: a chain, a fan-out to the marts list, a
fan-in to rollup, then check. on_failure_callback in default_args fires
alert_on_failure for any task that fails all its retries.
What each concept contributed#
| Concept | Where it shows up |
|---|---|
DAG + default_args | dag_id, retries, on_failure_callback |
schedule + catchup=False | @hourly, no backfill storm on enable |
| Interval-aware tasks | data_interval_start/end, ds in the callables |
| Idempotent tasks | delete-window / drop-partition before insert |
PythonOperator + external functions | every task points at a plain, testable function |
| Dependencies | >> marts >> rollup >> check fan-out / fan-in |
| XCom | read_watermark returns a value the load can pull |
| Connections / Hooks | clickhouse_client() reads clickhouse_default |
| Operability | on_failure_callback, quality gate, max_active_runs=1 |
Testing it#
airflow dags test orders_pipeline 2026-03-01T00:00:00airflow tasks test orders_pipeline load_raw_to_staging 2026-03-01T00:00:00airflow tasks test runs one callable against a real interval with no metadata
writes — the fastest loop for developing the functions.
Common mistakes#
Logic creeping into the DAG file#
The moment a transformation lives in dags/orders_pipeline.py, it runs on every
parse and cannot be unit-tested. Keep the file to imports and >>.
A mart task that appends#
Without the DROP PARTITION / DELETE WHERE, a retry doubles the day. Every
task in a scheduled DAG must be safe to run twice.
rollup before the marts finish#
load_staging >> marts >> rollup makes rollup wait for all three. Chaining it
after only one mart lets it run on partial data.
No failure alert#
on_failure_callback (or email_on_failure) is the difference between noticing
a broken run and a stakeholder noticing an empty dashboard.
Backfilling without max_active_runs=1#
Parallel backfill runs writing the same partitions collide. Serialise them.