Lesson 4 - Retries, Catchup & Backfill

Welcome to Retries, Catchup & Backfill

A task that never fails doesn’t need this lesson. A real one eventually will — a flaky network call, a database that’s briefly unreachable — and Airflow’s answer is to retry it automatically rather than mark the whole DAG dead on the first hiccup. Separately, a DAG with a real schedule raises two more questions this lesson answers with real evidence instead of definitions: what happens to runs Airflow “missed” while the DAG was paused (catchup), and how do you deliberately ask for historical runs anyway (backfill)?

By the end of this lesson, you will be able to:

  • Configure retries / retry_delay on a task and watch Airflow’s own retry logic — not a mocked failure — catch a real one
  • Confirm a retry actually happened using the REST API’s try_number field and the task’s real log lines
  • Explain catchup=False with real evidence: unpausing a DAG creates exactly the runs catchup allows, no more
  • Run a real airflow dags backfill and read its real, distinct execution_dates

A Task That Fails, For Real, On Purpose

cityflow_retry_demo_dag.py’s flaky_ingest task checks for a marker file. No marker means “this is attempt 1” — it drops one and raises. The marker existing means “this is a retry” — it succeeds and cleans up, leaving the DAG ready to fail the exact same way on its next real run:

# gate: skip
"""CityFlow's real retry demo -- flaky_ingest deliberately fails its first
attempt (a marker file it finds missing) and succeeds on the second,
configured with retries=1 / retry_delay so Airflow's own retry machinery,
not a mocked failure, is what gets observed. Module 3 Lesson 4."""
from datetime import datetime, timedelta

from airflow.decorators import dag, task

MARKER_PATH = "/opt/airflow/dags/data/work/retry_marker.txt"


@dag(
    dag_id="cityflow_retry_demo",
    schedule=None,
    start_date=datetime(2026, 1, 1),
    catchup=False,
    tags=["cityflow", "module-3", "retries"],
)
def cityflow_retry_demo_dag():
    @task(retries=1, retry_delay=timedelta(seconds=20))
    def flaky_ingest():
        """Every real run of this task fails its first attempt on purpose:
        no marker file means "this is attempt 1", so it drops one and
        raises. Airflow's own retry logic waits retry_delay, then tries
        again -- this time the marker exists, so it succeeds and cleans up,
        leaving the DAG ready to demonstrate the exact same failure on its
        next real run."""
        import os

        if not os.path.exists(MARKER_PATH):
            os.makedirs(os.path.dirname(MARKER_PATH), exist_ok=True)
            with open(MARKER_PATH, "w") as f:
                f.write("attempt-1-failed")
            raise RuntimeError(
                "simulated transient failure on attempt 1 -- Airflow should retry"
            )
        os.remove(MARKER_PATH)
        print("flaky_ingest succeeded on retry (attempt 2)")
        return "ok"

    @task
    def confirm_success(result: str) -> None:
        print(f"confirm_success received: {result!r} -- the retried task's real return value")

    confirm_success(flaky_ingest())


cityflow_retry_demo_dag()

retries=1 means Airflow will try this task a maximum of two times total before giving up; retry_delay=timedelta(seconds=20) means it waits 20 real seconds between attempt 1 and attempt 2 rather than hammering it immediately. Neither setting changes what the function does — they change what Airflow does when the function raises.


Triggered for Real — the Retry, Confirmed Three Ways

import subprocess
import time
import json

dag_id = "cityflow_retry_demo"

deadline = time.time() + 320
found = False
while time.time() < deadline:
    result = subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "details", dag_id],
        capture_output=True, text=True,
    )
    if result.returncode == 0:
        found = True
        break
    time.sleep(5)
assert found, f"{dag_id} was never picked up by the scheduler"

subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
    capture_output=True, text=True, check=True,
)
run_id = f"lesson4_retry_verify_{int(time.time())}"
subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1",
     "airflow", "dags", "trigger", dag_id, "--run-id", run_id],
    capture_output=True, text=True, check=True,
)
print(f"triggered {dag_id!r} as {run_id!r}")

# retry_delay=20s means this run genuinely takes >20s to reach a terminal state
deadline = time.time() + 90
state = "queued"
while state not in ("success", "failed") and time.time() < deadline:
    time.sleep(3)
    result = subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1",
         "airflow", "dags", "list-runs", "-d", dag_id, "--output", "json"],
        capture_output=True, text=True, check=True,
    )
    runs = json.loads(result.stdout)
    state = next(r["state"] for r in runs if r["run_id"] == run_id)

print(f"run {run_id!r} reached state: {state}")
assert state == "success", f"expected success, got {state}"

states = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1",
     "airflow", "tasks", "states-for-dag-run", dag_id, run_id],
    capture_output=True, text=True, check=True,
)
print(states.stdout)
triggered 'cityflow_retry_demo' as 'lesson4_retry_verify_1784885667'
run 'lesson4_retry_verify_1784885667' reached state: success
dag_id              | task_id         | state   | start_date                       | end_date
====================+=================+=========+==================================+=================================
cityflow_retry_demo | flaky_ingest    | success | 2026-07-24T09:34:50.586233+00:00 | 2026-07-24T09:34:50.659418+00:00
cityflow_retry_demo | confirm_success | success | 2026-07-24T09:34:51.043587+00:00 | 2026-07-24T09:34:51.106443+00:00

states-for-dag-run only shows a task’s final state, though — success here could mean “it succeeded on the first try,” which is not what happened. Three independent pieces of real evidence confirm the retry actually occurred:

1. The real gap in wall-clock time. This DAG run was created around 09:34:29 (visible in the run’s execution_date), but flaky_ingest didn’t start running its successful attempt until 09:34:50 — a ~20-second gap that matches retry_delay=timedelta(seconds=20) exactly, not something a first-try success would ever produce.

2. The REST API’s real try_number.

import requests

resp = requests.get(
    "http://localhost:8080/api/v1/dags/cityflow_retry_demo/dagRuns/"
    "lesson4_retry_verify_1784885667/taskInstances/flaky_ingest",
    auth=("airflow", "airflow"),
)
info = resp.json()
print("state:", info["state"])
print("try_number:", info["try_number"])
assert info["try_number"] == 2, f"expected try_number 2, got {info['try_number']}"
print("confirmed: flaky_ingest needed 2 tries to succeed")
state: success
try_number: 2
confirmed: flaky_ingest needed 2 tries to succeed

3. The real log lines from both attempts, read straight from the task’s two separate log files (Airflow 2.10 stores each attempt as its own attempt=<n>.log):

# attempt=1.log
RuntimeError: simulated transient failure on attempt 1 -- Airflow should retry
[...] INFO - Marking task as UP_FOR_RETRY. dag_id=cityflow_retry_demo, task_id=flaky_ingest, ...

# attempt=2.log
[...] INFO - flaky_ingest succeeded on retry (attempt 2)
[...] INFO - Done. Returned value was: ok

Marking task as UP_FOR_RETRY is Airflow’s own scheduler stating, in its own log, exactly what happened — not an inference from timing or an API field, the actual event.


Catchup and Backfill: Made Real, Not Just Defined

cityflow_daily_zone_snapshot_dag.py is a small @daily-scheduled DAG (start_date=datetime(2026, 7, 20), catchup=False) that snapshots CityFlow’s real zone count under whatever logical date it runs for:

# gate: skip
"""CityFlow's real catchup/backfill demo -- a small daily-scheduled DAG
(catchup=False) that snapshots the real zone count for whatever logical
date it runs under. Module 3 Lesson 4 unpauses it to show catchup=False
creates no historical runs on its own, then uses `airflow dags backfill`
to create real, distinct historical runs on purpose."""
from datetime import datetime

from airflow.decorators import dag, task
from airflow.operators.python import get_current_context

SNAPSHOT_DIR = "/opt/airflow/dags/data/work/snapshots"


@dag(
    dag_id="cityflow_daily_zone_snapshot",
    schedule="@daily",
    start_date=datetime(2026, 7, 20),
    catchup=False,
    tags=["cityflow", "module-3", "catchup", "backfill"],
)
def cityflow_daily_zone_snapshot_dag():
    @task
    def snapshot_zone_count():
        import json
        import os

        import pandas as pd

        context = get_current_context()
        ds = context["ds"]

        zones = pd.read_csv("/opt/airflow/dags/data/taxi_zone_lookup.csv")
        os.makedirs(SNAPSHOT_DIR, exist_ok=True)
        out_path = f"{SNAPSHOT_DIR}/{ds}.json"
        with open(out_path, "w") as f:
            json.dump({"logical_date": ds, "total_zones": len(zones)}, f, indent=2)
        print(f"snapshot for {ds}: {len(zones)} zones -> {out_path}")

    snapshot_zone_count()


cityflow_daily_zone_snapshot_dag()

start_date is four real days in the past by the time this lesson runs it. With catchup=True (Airflow’s default), unpausing this DAG would immediately schedule one run for every missed daily interval since start_date. With catchup=False, it does something much narrower:

import subprocess
import time
import json

dag_id = "cityflow_daily_zone_snapshot"

deadline = time.time() + 320
found = False
while time.time() < deadline:
    result = subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "details", dag_id],
        capture_output=True, text=True,
    )
    if result.returncode == 0:
        found = True
        break
    time.sleep(5)
assert found, f"{dag_id} was never picked up by the scheduler"

subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
    capture_output=True, text=True, check=True,
)
time.sleep(15)  # let the scheduler act on the unpause

result = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1",
     "airflow", "dags", "list-runs", "-d", dag_id, "--output", "json"],
    capture_output=True, text=True, check=True,
)
runs_after_unpause = json.loads(result.stdout)
print(f"runs immediately after unpause (catchup=False): {len(runs_after_unpause)}")
for run in runs_after_unpause:
    print(" ", run["execution_date"], run["state"])
runs immediately after unpause (catchup=False): 1
  2026-07-23T00:00:00+00:00 success

One run, for 2026-07-23 — the most recent completed daily interval — not four runs covering 07-20 through 07-23. That’s catchup=False’s real, observable behavior: it schedules forward from “now,” and simply never looks backward.

To genuinely get the historical runs catchup=False skipped, ask for them explicitly with a real backfill:

backfill = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1",
     "airflow", "dags", "backfill",
     "-s", "2026-07-20", "-e", "2026-07-22", "--reset-dagruns", "-y", dag_id],
    capture_output=True, text=True,
)
print("backfill exit code:", backfill.returncode)
print(backfill.stdout[-600:])
assert backfill.returncode == 0

result = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1",
     "airflow", "dags", "list-runs", "-d", dag_id, "--output", "json"],
    capture_output=True, text=True, check=True,
)
runs_after_backfill = json.loads(result.stdout)
print(f"\ntotal runs after backfill: {len(runs_after_backfill)}")
for run in sorted(runs_after_backfill, key=lambda r: r["execution_date"]):
    print(" ", run["execution_date"], run["run_id"], run["state"])
backfill exit code: 0
[...] INFO - [backfill progress] | finished run 3 of 3 | tasks waiting: 0 | succeeded: 3 | running: 0 | failed: 0 | ...
[...] INFO - Backfill done for DAG <DAG: cityflow_daily_zone_snapshot>. Exiting.

total runs after backfill: 4
  2026-07-20T00:00:00+00:00 backfill__2026-07-20T00:00:00+00:00 success
  2026-07-21T00:00:00+00:00 backfill__2026-07-21T00:00:00+00:00 success
  2026-07-22T00:00:00+00:00 backfill__2026-07-22T00:00:00+00:00 success
  2026-07-23T00:00:00+00:00 scheduled__2026-07-23T00:00:00+00:00 success

Four real runs now, with four distinct execution_dates: three genuinely new ones the backfill command created (07-20, 07-21, 07-22 — the backfill__ run-ID prefix names exactly how they were created), plus the one catchup=False had already scheduled on its own (07-23, prefixed scheduled__). Each real snapshot file confirms it ran under the date it claims to:

work/snapshots/2026-07-20.json: {"logical_date": "2026-07-20", "total_zones": 265}
work/snapshots/2026-07-21.json: {"logical_date": "2026-07-21", "total_zones": 265}
work/snapshots/2026-07-22.json: {"logical_date": "2026-07-22", "total_zones": 265}
work/snapshots/2026-07-23.json: {"logical_date": "2026-07-23", "total_zones": 265}

catchup and backfill answer two different questions: catchup controls what the scheduler does automatically when a DAG resumes; airflow dags backfill is something you ask for explicitly, any time, regardless of what catchup is set to.


Key Takeaways

  • retries / retry_delay configure Airflow’s own retry machinery on a task — this lesson’s flaky_ingest genuinely failed and genuinely retried, confirmed by a real ~20-second gap, a real try_number: 2 from the REST API, and a real Marking task as UP_FOR_RETRY log line.
  • catchup=False has a precise, observable effect: unpausing a DAG with a past start_date creates exactly one run (the most recent due interval), never the missed historical ones — confirmed here with a real runs immediately after unpause: 1.
  • airflow dags backfill -s ... -e ... creates real, dated historical runs on purpose, independent of the DAG’s catchup setting — this lesson’s real backfill created three runs for 2026-07-20 through 2026-07-22, each with its own real snapshot file to prove it.
  • A DAG run’s run_id prefix (backfill__... vs scheduled__... vs a manual --run-id) is itself real evidence of how that run was created, readable straight out of airflow dags list-runs.

Exercises

Exercise 1 — Change cityflow_retry_demo’s retries to 2 and deliberately make flaky_ingest fail on both of its first two attempts (track an integer counter in the marker file instead of a boolean marker). Confirm via the REST API that try_number reaches 3 before the task finally succeeds.

Exercise 2 — Run the exact same airflow dags backfill -s 2026-07-20 -e 2026-07-22 ... --reset-dagruns -y command a second time. Confirm it still exits 0 and still reports 3 successful runs — --reset-dagruns is what makes a backfill safe to re-run, by clearing the existing runs in that date range before recreating them.

Exercise 3 — Without --reset-dagruns, run airflow dags backfill -s 2026-07-20 -e 2026-07-22 -y cityflow_daily_zone_snapshot a second time and read what happens. Explain, from the real output, why Airflow refuses (or behaves differently) when asked to backfill a date range that already has completed runs and it isn’t told it may reset them.

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com