Lesson 05 Scheduling & Backfills
Theme:

Scheduling & Backfills

Lesson 05 · Data Engineering with Python · ~12 min read

1. The two clocks

Every Airflow run has two timestamps, and confusing them is the single most common mistake engineers make when first working with Airflow. The first timestamp is the logical date, also called the data interval start. It is the start of the time window whose data this run is responsible for processing. If the DAG is scheduled hourly and the window is 12:00–13:00, the logical date is 12:00, regardless of when the run actually fires.

The second timestamp is the physical execution time: the wall-clock time at which the run actually started. For a live scheduled run, the two are close together — the hourly run for 12:00–13:00 fires at roughly 13:00. But during a backfill, the physical execution time is today, even though the logical date might be six months ago. A task that reads datetime.utcnow() to figure out what data to fetch will fetch today's data during the backfill run for last November — producing wrong output that silently looks correct.

The rule is simple: always derive the data window from context["data_interval_start"] and context["data_interval_end"]. Never use datetime.utcnow() or any other wall-clock reading inside a task callable. The logical date is fixed for the lifetime of a DagRun; the physical execution time is not.

This distinction matters for retries too, not just backfills. If a task fails and Airflow retries it five minutes later, data_interval_start is identical in both attempts — but datetime.utcnow() would return a different value. A task that uses the logical date is therefore deterministic: every attempt for the same run produces the same result.

2. Schedule expressions

Airflow accepts several formats for the schedule parameter. The simplest are the preset strings: "@hourly", "@daily", "@weekly", and "@monthly". These map to standard cron patterns at fixed UTC boundaries. For more control, use a cron string directly: "0 6 * * *" for 06:00 UTC every day, or "15 * * * *" for 15 minutes past every hour. You can also pass a Python timedelta object for intervals that are not aligned to clock boundaries.

Expression First run fires at Interval length
"@hourly" Top of the next full hour after start_date 1 hour
"@daily" Midnight UTC the day after start_date 24 hours
"0 * * * *" Same as @hourly 1 hour
timedelta(hours=1) One hour after start_date 1 hour
Dataset trigger When the named Dataset is updated by a producer DAG event-driven

One important nuance: "@daily" runs at midnight UTC. If your start_date is datetime(2026, 5, 1), the first scheduled run fires at 2026-05-02T00:00:00Z, and it covers the interval 2026-05-01T00:00:00Z to 2026-05-02T00:00:00Z. This is correct behaviour — the run fires after the interval ends — but it surprises engineers who expect the first run to fire immediately at start_date.

3. Catchup behaviour

The catchup parameter controls what Airflow does when the scheduler first encounters a DAG (or when you turn on a previously-paused DAG) and there are past intervals between start_date and now that have never been run.

With catchup=True (the Airflow default), the scheduler queues a DagRun for every missed interval from start_date to now. Consider a daily DAG with start_date=datetime(2026, 1, 1) that you first turn on on 2026-05-26: Airflow will queue 146 runs (one per day from January 1 to May 25). For an hourly DAG with the same start_date, that is approximately 3,480 runs.

With catchup=False, Airflow skips all the missed intervals and starts from the most recent one. The same daily DAG turned on 2026-05-26 gets exactly one queued run (for 2026-05-25). This is the right choice for alerting or monitoring pipelines where old intervals are irrelevant.

Choose deliberately. The safest approach for a new pipeline is to start with catchup=False and run backfills manually with the airflow dags backfill CLI command when you need history. This gives you full control over the rate and ordering of historical runs.

4. The data-interval-aware DAG

The pattern for writing a data-interval-aware task is straightforward: read the interval from context["data_interval_start"] and pass it to your extract or transform function as a parameter. The key is never to derive "what to process" from a wall-clock reading inside the task body.

For the gharchive pipeline, the extract function needs the hour string in the format YYYY-MM-DD-H (gharchive's URL convention, with no zero-padding on the hour). The correct way to derive it:

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",
    )

Because data_interval_start is a Pendulum DateTime object (Airflow uses Pendulum internally), strftime works exactly as it does on a standard Python datetime. The %-H format specifier strips the leading zero from the hour on Linux and macOS (e.g., 7 instead of 07), matching gharchive's URL convention.

Now replaying hour 7 of 2026-05-01 always calls fetch_hour(hour="2026-05-01-7"), whether the run fires today, next year, or during a manual backfill. The task is deterministic and replayable.

5. Manual backfills

The airflow dags backfill CLI command enqueues DagRuns for a specified date range. It is the correct tool when you want to re-process historical data — for example, because you deployed a new transform and need to apply it to the last month, or because a data bug was found and you need to replay the affected days.

airflow dags backfill gharchive_hourly \
    --start-date 2026-05-01 \
    --end-date 2026-05-01T06:00:00

This enqueues six DagRuns, covering the intervals 00:00–01:00, 01:00–02:00, ..., 05:00–06:00 of 2026-05-01. The runs execute concurrently up to max_active_runs. Because the DAG is data-interval-aware, each run independently fetches its own hour — no manual parameter changes needed.

Two practical notes. First, backfill runs are marked with a backfill__ prefix in their run_id, which distinguishes them from regularly scheduled runs in the Grid view. Second, if the DagRuns already exist (from a previous scheduled run), you must pass --reset-dagruns to allow the backfill to overwrite them. Without that flag, the command silently skips existing runs.

6. Concurrency controls

Three parameters govern how many tasks run simultaneously for a DAG.

max_active_runs (default: 16) limits the number of DagRun instances that can be in the running state at the same time for a given DAG. Setting it to 2 means at most two hourly intervals are being processed concurrently. This is the primary knob for rate-limiting an extract that calls an external API.

max_active_tasks (default: 16) limits the total number of task instances that can be running across all concurrent DagRuns for a given DAG. If you have max_active_runs=4 and each run has 3 tasks, the theoretical maximum concurrency is 12 task instances — but you can cap it lower with max_active_tasks.

Pools are named slot budgets that apply globally, across all DAGs. Create a pool called gharchive_api with 4 slots (in the Airflow UI under Admin → Pools), then wire the extract task to use it:

PythonOperator(
    task_id="extract",
    python_callable=_extract,
    pool="gharchive_api",
)

Now at most 4 extract tasks run at once globally, regardless of how many DAGs reference gharchive_api. This is the correct tool when multiple DAGs all call the same external API and you need a single throttle.

7. Common pitfalls

Warning — three patterns that will cause you pain:
  1. catchup=True with a 6-month start_date. Turning on an hourly DAG with start_date=datetime(2025, 11, 1) on 2026-05-26 queues approximately 4,320 runs instantly. The scheduler enqueues them all, and if max_active_runs is at the default of 16, 16 extract tasks immediately start hammering gharchive.org in parallel. Always pair catchup=True with a low max_active_runs (1 or 2) until the backlog drains.
  2. datetime.utcnow() inside a task body. Every replay sees a different physical time. The output is non-deterministic, and the second run for the same interval will silently produce different (wrong) data. Use context["data_interval_start"] instead.
  3. logical_date named misleadingly. The logical_date field (previously called execution_date) is the start of the interval being processed, not the wall-clock time the run fired. If you want "the latest data this run has access to", use data_interval_end. A daily run with logical_date=2026-05-01T00:00:00 sees data up to data_interval_end=2026-05-02T00:00:00.

References: Airflow Scheduler docs · Time Zones · Datasets feature · Backfill CLI reference