Scheduling, Data Intervals & the Logical Date Trap
Why a daily DAG for Monday runs on Tuesday. Data intervals, logical dates, catchup, timezones, and the start_date mistakes everyone makes once.
⏱ 11 min readTopics chapter readerLevel · Medium
01 · Start here
Start Here
⏱ 5 min · Topic 1 of 11
Your shop runs one job every day. The job builds daily_revenue for one day. Airflow is the tool that starts this job on a schedule.
You already know the logical date: the day a run is about. In this chapter you learn what Airflow means by it, and where the trap is.
You will answer three questions:
When does each daily run start?
Which orders does each run read?
What happens when you turn on a DAG with an old start date?
A paid order counts as revenue. Refunded and pending orders do not. Every example uses the shop table below:
orders
id
customer_id
status
amount
order_date
101
1
paid
40.00
2026-01-03
102
1
paid
25.00
2026-01-10
103
2
refunded
60.00
2026-01-11
104
3
paid
15.50
2026-01-15
105
3
pending
NULL
2026-01-20
106
4
paid
80.00
2026-01-21
02 · The setup
A Daily DAG
⏱ 6 min · Topic 2 of 11
You met the DAG in the last chapter: tasks and arrows. Here it is written as code.
A DAG run is one time the DAG runs.
A daily DAG makes one DAG run per day. Each run builds daily_revenue for one day.
Look at the settings that decide when runs exist:
The daily revenue DAGworked example
Python
import pendulum
from airflow.sdk import DAG
from airflow.timetables.interval import CronDataIntervalTimetable
with DAG(
dag_id="daily_revenue",
schedule=CronDataIntervalTimetable("0 0 * * *", timezone="UTC"),
start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
catchup=False,
) as dag:
... # the tasks go here
The schedule line means: one run for each day, cut at 00:00 UTC. In Airflow 3.1, airflow.sdk does not include this timetable, so it comes from airflow.timetables.interval.
The schedule is the rule that creates runs. Here it is a cron string, "0 0 * * *", which means every day at 00:00. A timetable is the object that turns that rule into runs.
The start_date is where the first run begins: 2026-01-01. The catchup setting decides what happens to days that are already over. A later section explains it.
03 · The mechanism
Data Intervals: When Runs Start
⏱ 9 min · Topic 3 of 11
Every run has a data interval. It is the span of time the run is about.
For a daily DAG, one interval is one full day. It starts at 00:00 and ends at the next 00:00.
The start is included. The end is not.
Airflow creates a run only after its interval has ended. So the run about 2026-01-10 starts around 2026-01-11 00:00. Look at the timeline:
Two daily runs on a timelineworked example
Config
time -> Jan 10 00:00 Jan 11 00:00 Jan 12 00:00
|<-- interval A --->|<-- interval B --->|
orders of Jan 10 orders of Jan 11
^ run A starts here
^ run B starts here
Run A is about Jan 10. It starts when Jan 10 is over. Run B is about Jan 11 and starts a day later.
Airflow gives each run these values. The table shows them for the run about 2026-01-10:
Name
Value
Meaning
data_interval_start
2026-01-10 00:00
First moment of the interval. It is included.
data_interval_end
2026-01-11 00:00
End of the interval. It is not included.
logical_date
2026-01-10 00:00
The day the run is about. Here it equals the start.
Real start time
about 2026-01-11 00:00
When Airflow starts the run. It can be a little later.
The logical date is a label for the day. It is not the time the run starts.
In Airflow 2, the logical date was also called execution_date. Airflow 3 removed that old name, but many tutorials still use it.
Interview question: why did my DAG run a day late?
It did not run late. The run labeled 2026-01-10 waits for 2026-01-10 to end, so it starts on 2026-01-11. The label shows the day the data is about, not the day the run starts.
Now see which orders each run reads. Every run reads the orders whose order_date is inside its interval:
Run (logical date)
Run starts
Interval read
Orders read
2026-01-10
2026-01-11 00:00
01-10 up to 01-11
102
2026-01-11
2026-01-12 00:00
01-11 up to 01-12
103
2026-01-15
2026-01-16 00:00
01-15 up to 01-16
104
2026-01-20
2026-01-21 00:00
01-20 up to 01-21
105
2026-01-21
2026-01-22 00:00
01-21 up to 01-22
106
Order 106 is made on 2026-01-21. No run reads it until the day is over, on 2026-01-22.
04 · The fix
Read the Interval in SQL
⏱ 6 min · Topic 4 of 11
An operator is a ready-made kind of task. SQLExecuteQueryOperator runs SQL on a database. The task_id names the task, and conn_id is the name of the saved database login.
A template is text with {{ }} holes in it. Airflow fills each hole when the run starts. Use the data interval values to pick the rows.
The first statement deletes the day, so a rerun is safe. The second builds the day:
A task that reads its own intervalworked example
Python
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator
build_revenue = SQLExecuteQueryOperator(
task_id="build_daily_revenue",
conn_id="shop_db",
sql=[
"DELETE FROM daily_revenue WHERE revenue_date = '{{ data_interval_start | ds }}'",
"""
INSERT INTO daily_revenue (revenue_date, revenue)
SELECT DATE '{{ data_interval_start | ds }}', COALESCE(SUM(amount), 0)
FROM orders
WHERE status = 'paid'
AND order_date >= '{{ data_interval_start | ds }}'
AND order_date < '{{ data_interval_end | ds }}'
""",
],
)
The WHERE lines are the half-open window from the incremental chapter: start included, end not included.
In a template, | ds turns a timestamp into text like 2026-01-10. The short name {{ ds }} is the logical date as text. With this timetable it equals the start.
Airflow fills in the template for the run about 2026-01-10. This is the query the database receives:
The rendered query for the 2026-01-10 runworked example
Only order 102 has order_date 2026-01-10 and status paid. The other five orders are on other days.
Order 103 has the next day, 2026-01-11, so it belongs to the next run. That run gets 0.00, because 103 is refunded.
05 · The trap
The Trap: Using Now
⏱ 5 min · Topic 5 of 11
A common mistake is to ask the computer clock for the day, instead of reading the interval. The code looks fine:
Picking the day with the clockworked example
Python
from datetime import date, timedelta
day = date.today() - timedelta(days=1) # "yesterday" by the clock
This answer depends on the day the code runs, not on the day the run is about.
On the first try, this works. The run about 2026-01-10 starts on 2026-01-11, so "yesterday" is 2026-01-10. It breaks when the run happens at another time:
When the run about 2026-01-10 executes
Clock says yesterday
Reads order
Result
On time, 2026-01-11 00:05
2026-01-10
102
Right: 25.00
Retry the next day, 2026-01-12 09:00
2026-01-11
103 (refunded)
Wrong: 0.00
Rerun after a fix, 2026-01-25
2026-01-24
none
Wrong: 0.00
The interval always says 2026-01-10, so it always reads order 102 and gives 25.00.
No task fails. The number is simply wrong, and it looks normal. The interval fixes this because it never changes for one run.
Interview question: why use the data interval?
It ties a run to one fixed day. A retry, a rerun and a backfill read the same rows. A clock gives different rows each time.
06 · Airflow 2 and 3
Airflow 3 Changed the Default
⏱ 7 min · Topic 6 of 11
The first DAG chose its timetable on purpose. Airflow has two main kinds, and they treat the same cron string differently.
A tick is a moment when the schedule fires. Look at the same tick, 2026-01-11 00:00, under both:
At the tick 2026-01-11 00:00
Data interval timetable
Trigger timetable
Class name
CronDataIntervalTimetable
CronTriggerTimetable
logical_date
2026-01-10 00:00
2026-01-11 00:00
data_interval_start
2026-01-10 00:00
2026-01-11 00:00
data_interval_end
2026-01-11 00:00
2026-01-11 00:00
Covers a window?
Yes, one full day
No, the start equals the end
In Airflow 2, a plain cron string such as "0 0 * * *" or "@daily" used the data interval timetable. In Airflow 3, it uses the trigger timetable by default. Airflow 3 has a setting for this:
The setting that picks the timetableworked example
No order can be on or after 2026-01-10 and before 2026-01-10. Order 102 is skipped, and the task still shows green. The label still says 2026-01-10, so the run looks right.
The fix is the one you already used. Pass the timetable yourself, as in the first DAG, so the DAG means the same on every setup.
Old tutorials
A tutorial that says "the run starts after the interval ends" teaches the data interval timetable. A tutorial that says "the run starts at the tick" teaches the trigger timetable. Check which one before you trust a date.
07 · History
Catchup and start_date
⏱ 8 min · Topic 7 of 11
The start_date is the first day the DAG may have runs. Catchup decides what Airflow does with intervals that ended before you turned the DAG on.
This uses the data interval timetable from the first DAG.
Say start_date is 2026-01-01, and you turn the DAG on at 2026-01-21 08:00. Twenty intervals have already ended: 2026-01-01 up to 2026-01-20. Look at both settings:
catchup
Runs created at once
Logical dates
True
20 runs
2026-01-01 up to 2026-01-20
False
1 run, the latest interval
2026-01-20 only
In both cases, the next run is about 2026-01-21 and starts on 2026-01-22 00:00.
With catchup on, the runs build the history. Here are the runs that find orders:
Run (logical date)
Orders read
daily_revenue
2026-01-03
101
40.00
2026-01-10
102
25.00
2026-01-11
103 (refunded)
0.00
2026-01-15
104
15.50
2026-01-20
105 (pending)
0.00
The other 15 days
none
0.00
That is 20 runs: five with orders and 15 empty. With catchup off, only the 2026-01-20 run exists, and the earlier days are never built.
Set catchup on every DAG. If you do not, the default comes from the setting catchup_by_default. It is False in Airflow 3 and it was True in Airflow 2.
Always write start_date as a fixed date. A moving value like datetime.now() changes every time Airflow reads your file. The DAG may then never run.
Interview question: what does catchup do?
It decides whether Airflow creates runs for past intervals that were missed. On means one run for every missed interval. Off means only the latest one.
Watch out
Twenty runs at once, or 365, can overload your source database. Set max_active_runs=1 to run them one at a time. The Airflow chapter Backfills, Catchup, Retries & Deadlines explains this.
08 · Fill the gaps
Backfill a Range
⏱ 5 min · Topic 8 of 11
A backfill creates runs for a range of dates that you choose. Use it when runs are missing. For example, catchup was off, or a breakdown skipped days.
Say the runs for 2026-01-10 to 2026-01-12 do not exist. This command makes one run for each logical date in that range:
Each run reads its own day, even though all three start today.
It works the same for any range. Each run keeps its own interval.
This is why the interval matters. Each backfilled run asks for its own day, so it never reads today. A clock-based query would read the same wrong day three times.
A backfill runs your tasks again and again. The DELETE before the INSERT keeps that safe. The Airflow chapter Backfills, Catchup, Retries & Deadlines covers the limits and the safe way to run big backfills.
09 · Edge case
Time Zones: Use UTC
⏱ 5 min · Topic 9 of 11
UTC is one time for everyone, with no clock changes. You met it in the Python chapters. Airflow can also schedule in a local time zone.
Daylight saving time means clocks move one hour forward in spring and one hour back in autumn.
In a local zone with daylight saving time, some days are not 24 hours long. Look at the shop in Berlin (Europe/Berlin):
Day
Length in Europe/Berlin
Length in UTC
2026-03-29
23 hours: clocks jump from 02:00 to 03:00
24 hours
2026-10-25
25 hours: clocks go from 03:00 back to 02:00
24 hours
Any other day
24 hours
24 hours
Code that says "the window is the run time minus 24 hours" is wrong on those two days. The interval from a time-zone-aware timetable already has the right length.
So schedule in UTC, as in the first DAG. Set timezone="UTC" on the timetable and use a UTC start_date. Convert to local time only when you show results.
10 · Recap
Quick Recap
⏱ 5 min · Topic 10 of 11
Seven things to remember:
A run has a data interval: the span of time it is about.
With a data interval timetable, a run starts after its interval ends. The run about 2026-01-10 starts on 2026-01-11.
The logical date is the day the run is about. In Airflow 2 it was also called execution_date.
Read data_interval_start and data_interval_end in your SQL. Never use now() to pick the data day.
Airflow 3 uses the trigger timetable for a plain cron string, so the start equals the end. Choose your timetable on purpose.
Catchup decides whether past intervals get runs. A backfill makes runs for a range you choose.
Schedule in UTC, with a fixed start_date.
Check yourself:
A DAG with catchup on and start_date 2026-01-01 is turned on at 2026-01-04 00:30. Which logical dates get runs?
Which order does the run about 2026-01-15 read, and when does it start?
A task returns 0.00 for a day with a paid order. Name two causes.
Answers:
2026-01-01, 2026-01-02 and 2026-01-03. The 01-03 interval ended at 2026-01-04 00:00.
It reads order 104, and it starts on 2026-01-16 00:00.
The query used the clock instead of the interval, or a trigger timetable made the start equal the end.
11 · Next Chapter
Next Chapter
Your daily job now reads the right day. Next you learn the parts a DAG is built from.
⏱ 3 min · Topic 11 of 11
Next chapter
Operators, Sensors & Hooks In Depth
Your daily job now reads the right day. Next you learn the parts a DAG is built from.
You meet operators, sensors and hooks, and see how each one works.