Schedules, catchup, and the data-interval-aware DAG pattern.
Data Engineering Module 2 Orchestrationdatetime.utcnow() inside a task gives a different answer on every replayA correctly written task processes the data interval it was given, not the wall-clock time it runs at.
The data we are processing. The start of the interval is fixed — a backfill run for hour 12 always has data_interval_start = 2026-05-01T12:00:00, whether it runs today or next year.
The wall-clock time the run actually fired. This changes on every replay. Never use it to derive which data to process — it breaks backfills.
| Expression | Interval length | Notes |
|---|---|---|
"@hourly" | 1 hour | Fires at the top of every hour (UTC) |
"@daily" | 24 hours | Fires at midnight UTC; first run covers the previous day |
"0 * * * *" | 1 hour | Equivalent cron; more flexible for offset schedules |
timedelta(hours=1) | 1 hour | Python object; useful when the interval is computed |
| Dataset trigger | event-driven | Fires when an upstream DAG produces a named Dataset (Airflow 2.4+) |
When you set start_date=2026-05-01 and the scheduler starts on 2026-05-26 with
catchup=True, Airflow queues every missed interval from
start_date to now — approximately 600 hourly runs in that example.
With catchup=False, Airflow skips straight to the most recent interval.
All missed intervals are queued. Right choice when historical completeness matters (e.g., loading a data warehouse from day one).
Skip the backlog, start from now. Right choice for alerting pipelines or dashboards where old data is irrelevant.
Choose deliberately — the default in Airflow is True.
# L04 (hardcoded — breaks on backfill):
def _extract(**context):
from extract.gharchive import fetch_hour
fetch_hour(hour="2026-05-01-12")
# L05 (data-interval-aware — safe to replay):
def _extract(**context):
from extract.gharchive import fetch_hour
hour = context["data_interval_start"].strftime("%Y-%m-%d-%-H")
fetch_hour(hour=hour)
Now every run processes its own hour. Replaying the same interval always fetches the same data.
contextdata_interval_start — pendulum DateTime; start of the interval this run processesdata_interval_end — pendulum DateTime; end of the intervallogical_date — alias for data_interval_start (Airflow 2.2+)run_id — unique identifier for this DagRuntask_instance — access XCom, retry count, etc.params — JSON payload passed at trigger timedef _extract(**context):
print(context["data_interval_start"]) # pendulum DateTime
print(context["run_id"]) # 'scheduled__2026-05-01T12:00:00+00:00'
airflow dags backfill gharchive_hourly \
--start-date 2026-05-01 \
--end-date 2026-05-01T06:00:00
This enqueues 6 DagRuns, one per hourly interval (hours 0–5). Watch them parallel up to max_active_runs.
Each backfill run gets its own data_interval_start. Because our DAG is data-interval-aware, every run downloads exactly the right hour — no code changes needed to backfill.
max_active_runs and concurrencymax_active_runs=16)max_active_tasks caps the total number of running task instances across all concurrent DagRunswith DAG(
dag_id="gharchive_hourly",
max_active_runs=2, # at most 2 concurrent runs — polite to gharchive.org
max_active_tasks=8,
...
) as dag: ...
"If you set catchup=True with start_date six months ago and
schedule='@hourly', you just told Airflow to queue 4,300 runs. Then your extract starts
hammering gharchive.org. Always pair high-catchup DAGs with max_active_runs ≤ 2."
max_active_runs=1, watch the queue drain, then raise
Airflow 2.4+ added Datasets: declare that a DAG produces
Dataset("s3://bucket/path") and another DAG consumes it, and the second DAG
auto-triggers when the first finishes writing to that path. This replaces fragile
ExternalTaskSensor patterns for cross-DAG dependencies.
from airflow.datasets import Dataset
HOURLY_OUT = Dataset("file:///opt/airflow/sample-pipeline/data/")
# Producer DAG
with DAG(..., schedule="@hourly") as hourly_dag:
PythonOperator(..., outlets=[HOURLY_OUT])
# Consumer DAG — fires when HOURLY_OUT is updated
with DAG(..., schedule=[HOURLY_OUT]) as rollup_dag:
...
Not used in this course's sample pipeline, but worth knowing for production setups.
def _hour_key(context) -> str:
"""Format data_interval_start as YYYY-MM-DD-H (gharchive filename shape)."""
return context["data_interval_start"].strftime("%Y-%m-%d-%-H")
def _extract(**context) -> None:
from extract.gharchive import fetch_hour
hour = _hour_key(context)
fetch_hour(hour=hour)
def _transform(**context) -> None:
from transform.events import count_by_type
hour = _hour_key(context)
count_by_type(
input_path=f"data/raw/{hour}.json.gz",
output_path=f"data/out/{hour}.csv",
)
with DAG(
dag_id="gharchive_hourly",
start_date=datetime(2026, 5, 1),
schedule="@hourly", # ← L05: was None
catchup=True, # ← L05: was False
max_active_runs=2,
tags=["data-engineering", "module-2"],
) as dag:
extract = PythonOperator(task_id="extract", python_callable=_extract)
transform = PythonOperator(task_id="transform", python_callable=_transform)
extract >> transform
logical_date with "now" — logical_date is the
start of the interval being processed, not the wall clock when the task ran. A run triggered
today for last week's data has logical_date = last week.
datetime.utcnow() inside a task — every replay sees a different
time. The output is non-deterministic. Use context["data_interval_start"] instead.
catchup=False delays the first run — if you add the
DAG at 09:47, the first run fires at 10:00 (after the first full interval elapses), not immediately.
context["data_interval_start"], never datetime.utcnow()catchup=True/False deliberately; always pair with a sane max_active_runs