Two Timetables for One Cron

Concepts covered: Schedules and timetables

Write schedule="0 6 * * *" in a 3.3 DAG and Airflow builds a CronTriggerTimetable: one run at each 06:00, named for that 06:00, with a window that starts and ends at the same instant. Write schedule=CronDataIntervalTimetable("0 6 * * *", timezone="UTC") and each 06:00 closes a window 24 hours long, and the run that appears at that moment is named for the 06:00 before it. Both grids show a new run at 06:00 on 4 November. One calls it the 4th, the other the 3rd.

The string is the same; the reading differs. A trigger timetable answers the question of when the DAG should fire. A data-interval timetable answers the question of which slice of time has become complete. A deployment can be configured to read bare strings the second way, so a file that spells out its timetable class behaves the same on every deployment that parses it. Presets take the same route: @daily is 0 0 * * * and gets whichever reading a bare string gets.

from airflow.timetables.interval import CronDataIntervalTimetable
from airflow.timetables.trigger import CronTriggerTimetable

fires_at_six = CronTriggerTimetable("0 6 * * *", timezone="UTC")
closes_at_six = CronDataIntervalTimetable("0 6 * * *", timezone="UTC")

What the scheduler does at 06:00

Both runs that appear at 06:00 on the 4th carry a run_after of 06:00 on the 4th and can start at that second. On the trigger reading run_after equals the logical date; on the data-interval reading it equals the window's end, a day after the name. Their windows are what differ. Point section 0's half-open filter at the trigger run and it asks for rows at or after 06:00 and strictly before 06:00. No row satisfies both, so the count comes back 0. The task still succeeds, and the grid shows green over an empty load.

CronTriggerTimetable
  • Fires at the cron instant
  • logical_date is that instant
  • Window has zero length unless you pass interval
  • First run with catchup: the first cron instant after start_date
  • Suits work done at a time: sends, pokes, snapshots
CronDataIntervalTimetable
  • Runs when a window closes
  • logical_date is the window's start
  • Window spans one cron period
  • First run with catchup: one period after the first instant after start_date
  • Suits work done on a slice: loads, aggregates, partitions

Telling them apart from a run

Open any scheduled run in the UI. When data_interval_start equals data_interval_end, the DAG has a trigger timetable. When the run that appeared at 06:00 is named for the 06:00 of the day before, it has a data-interval timetable. The first run after a start_date tells too. With catchup on and a start_date of 1 November, the trigger version's first run appears at 06:00 on the 1st and is named 1 November 06:00. The interval version's first window opens at 06:00 on the 1st and its first run appears at 06:00 on the 2nd, also named 1 November 06:00, so the 2 first runs share a name and appear 1 day apart.

A trigger with a window

from datetime import timedelta

from airflow.timetables.trigger import CronTriggerTimetable

schedule = CronTriggerTimetable(
    "0 6 * * *",
    timezone="UTC",
    interval=timedelta(days=1),
)

interval gives a trigger run a window that ends at the firing instant while its logical date stays that instant. The run at 06:00 on the 4th now has a window from the 3rd at 06:00 to the 4th at 06:00 and a name of the 4th at 06:00. Name and window start differ by a day, which is the case section 0 warned about: a task that treats logical_date as the window's start is wrong by 24 hours here, and one that reads data_interval_start is right. Use this form when a DAG must fire at a wall-clock time and also report on the period behind it.

from datetime import (
    UTC, datetime)
from airflow.sdk import DAG, task
from airflow.timetables.interval \
  import CronDataIntervalTimetable

cron = CronDataIntervalTimetable(
    "0 6 * * *", timezone="UTC")

with DAG("daily_report",
    schedule=cron, catchup=True,
    start_date=datetime(
        2026, 11, 1, tzinfo=UTC)):
    @task
    def send_report(): pass
    send_report()
  • data-interval reading
  • trigger reading (interval=1 day)
Switch the timetable: each 06:00 run is named for its window's start, or for the 06:00 it fires at.

About this lesson

This section is part of the Scheduling: Intermediate lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.