If you upgrade a DAG like this from Airflow 2 to Airflow 3 without changing a line, it can start processing a different day than before:
@dag(schedule="0 0 * * *", start_date=pendulum.datetime(2026, 10, 1, tz="UTC"))
def daily_orders():
@task
def extract(ds=None):
load_orders_for(ds) # which day is ds?
extract()
Nothing is broken. The default timetable changed. Here is what is going on and how to make your DAGs independent of it.
Airflow 2: a run covers an interval and starts after it ends
In Airflow 2, [scheduler] create_cron_data_intervals defaults to True, so a bare cron string becomes a CronDataIntervalTimetable. Each run covers a data interval and is started after the interval ends:
| Run | logical_date | data interval | actually starts |
|---|---|---|---|
| "Oct 1 run" | 2026-10-01 00:00 | [Oct 1 00:00, Oct 2 00:00) | shortly after Oct 2 00:00 |
logical_date (and therefore ds) equals data_interval_start: the day being processed. This confused a generation of engineers ("why does the Oct 1 run start on Oct 2?"), but it made "process yesterday" DAGs work naturally.
Airflow 3: a run is triggered at the cron time
In Airflow 3 the default is create_cron_data_intervals = False, so a bare cron string becomes a CronTriggerTimetable. The run starts at the cron time, logical_date is the trigger time, and data_interval_start and data_interval_end are normally the same moment unless you configure an interval.
| Run | logical_date | data interval | actually starts |
|---|---|---|---|
| run at Oct 2 00:00 | 2026-10-02 00:00 | Oct 2 00:00, Oct 2 00:00 | Oct 2 00:00 |
So the run that starts at midnight on October 2nd has ds = 2026-10-02 in Airflow 3, but processed 2026-10-01 in Airflow 2. If your task does WHERE order_date = '{{ ds }}', it now loads the current (incomplete) day.
The official upgrade guide calls this out: if you rely on data_interval_start/data_interval_end or on ds/ts, either set create_cron_data_intervals=True before upgrading or pin an explicit timetable. (Another change while you are there: catchup now defaults to False.)
The fix: stop depending on the default
Pin the timetable in the DAG and give it an explicit interval. Then the DAG behaves the same regardless of the global config:
from datetime import timedelta
import pendulum
from airflow.sdk import dag, task
from airflow.timetables.trigger import CronTriggerTimetable
@dag(
schedule=CronTriggerTimetable("0 0 * * *", timezone="UTC", interval=timedelta(days=1)),
start_date=pendulum.datetime(2026, 10, 1, tz="UTC"),
catchup=False,
)
def daily_orders():
@task
def extract(data_interval_start=None, data_interval_end=None):
# run at 2026-10-02 00:00 gets [2026-10-01 00:00, 2026-10-02 00:00)
load_orders(window_start=data_interval_start, window_end=data_interval_end)
extract()
daily_orders()
Note: even with interval=, logical_date (and ds) is the trigger time (Oct 2), so template on data_interval_start/data_interval_end, not on ds.
And make the task read the window it is given, never "now":
# bad: depends on when the task happens to run (retries, backfills, catchup all break)
load_orders(window_start=date.today() - timedelta(days=1))
# good: depends only on the run
load_orders(window_start=data_interval_start, window_end=data_interval_end)
A checklist before you upgrade
-
grepyour DAGs fords,ds_nodash,ts,logical_date,execution_date(removed in Airflow 3),data_interval_start. - For each DAG with a bare cron string, decide: interval semantics or trigger semantics? Pin it explicitly.
- Check manual triggers: Airflow 3's upgrade guide warns not to assume a manually triggered run's
data_intervalequals thelogical_dateyou typed in. - Make tasks idempotent (delete-and-replace the run's window, or
MERGEby key), so a run that processed the wrong day can simply be rerun. - Add a test that imports every DAG and asserts the timetable class you expect.
This is a condensed version of one question from the Data Engineering Interview Kit 2026. Interviewers love it because it tests whether you understand why idempotent, interval-based tasks matter. The free sample with 20 full questions (answers, trade-offs, common mistakes, follow-ups) is here: https://payhip.com/b/18A5n
About me: I work as an independent data engineer and DevOps freelancer (IT-Craftsman) and have delivered several data engineering and DevOps projects for large companies. I also work for a technology consulting firm, where I conduct hiring interviews for data engineers and DevOps engineers.
Facts checked against the Airflow 3 documentation ("Upgrading to Airflow 3", "Timetables").
Top comments (0)