Airflow Schedule Intervals, Cron Expressions & Timetables
The schedule parameter on a DAG (formerly schedule_interval, renamed in Airflow 2.4) controls when the scheduler considers a DAG due to run. It accepts several different forms, and understanding them — plus the non-obvious relationship between “schedule” and “when the DAG actually executes” — avoids a lot of confused debugging.
The Three Ways to Specify a Schedule
Cron expressions — full control over exact timing:
schedule="0 2 * * *" # every day at 2:00 AMschedule="*/15 * * * *" # every 15 minutesschedule="0 9 * * 1-5" # 9 AM, Monday through FridayPresets — readable shorthand for common patterns:
schedule="@daily" # equivalent to "0 0 * * *"schedule="@hourly" # equivalent to "0 * * * *"schedule="@weekly" # equivalent to "0 0 * * 0"schedule=None # DAG only runs when triggered manually or via the API — no automatic scheduletimedelta objects — a fixed interval from the last run, rather than a fixed clock time:
from datetime import timedeltaschedule=timedelta(hours=6) # every 6 hours, relative to start_dateThe Logical Date Trap
This is the single most common source of Airflow scheduling confusion: a DAG run scheduled for a given interval doesn’t execute at the start of that interval — it executes at the end of it.
A DAG with schedule="@daily" and a run for logical date 2026-01-01 doesn’t kick off at midnight on Jan 1st — it kicks off at midnight on Jan 2nd, once the full Jan 1st interval has completely elapsed. This is intentional: the interval represents “here is a full day of data to process,” and Airflow can’t process a full day’s data until that day is actually over.
# A daily DAG scheduled for logical_date 2026-01-01# runs at wall-clock time 2026-01-02 00:00, once the Jan 1 interval has closed.Airflow 2.2+ renamed the old, frequently-misunderstood execution_date to logical_date specifically to make this clearer — it represents the start of the data interval being processed, not the time the DAG actually ran. Both terms appear in code and docs depending on the Airflow version in a given codebase.
Custom Timetables
For schedules that no cron expression can represent — “the last business day of every month,” “every trading day, skipping market holidays” — Airflow supports custom Timetable classes that implement arbitrary scheduling logic in Python:
from airflow.timetables.trigger import CronTriggerTimetable
# CronTriggerTimetable interprets the cron expression as the actual trigger time# rather than the end of a data interval — useful when you want "run at 2 AM"# to mean literally that, not "process the interval that ended near 2 AM."schedule=CronTriggerTimetable("0 2 * * *", timezone="UTC")Custom timetables are an advanced feature reserved for genuinely irregular schedules — for the vast majority of DAGs, a cron expression or preset is sufficient and easier for a new team member to read at a glance.
Timezones
By default, schedules are interpreted in UTC unless a timezone is explicitly set (via pendulum.timezone(...) passed to start_date, or a Timetable’s own timezone parameter) — a common production bug is a DAG intended to run “at 9 AM local time” actually running at 9 AM UTC, silently offset by however many hours the local timezone differs from UTC, including shifting by an hour twice a year wherever daylight saving time applies.
Common Mistakes
Expecting a @daily DAG’s logical-date-X run to start at the beginning of day X, rather than the beginning of day X+1 — this single misunderstanding accounts for a large share of “why didn’t my DAG run when I expected” questions.
Hardcoding schedules in local time without an explicit timezone, causing runs to silently drift relative to intended wall-clock time around DST transitions.
Using a timedelta schedule when a cron expression better expresses intent. timedelta(days=1) and @daily are not equivalent if the DAG is ever paused and resumed — timedelta schedules run relative to the last actual run, while cron-based schedules stay locked to fixed clock times regardless of pauses.
Frequently Asked Questions
What does schedule=None mean? The DAG exists and can be triggered manually or via the API/another DAG, but the scheduler will never automatically queue a run for it — useful for on-demand or externally-triggered pipelines.
Can I change a DAG’s schedule after it’s been running? Yes, but be aware that changing the schedule doesn’t retroactively rewrite history — existing DAG runs keep their original logical dates, and Airflow may require the dag_id to change (or manual metadata cleanup) if the schedule change is significant enough to create logical-date collisions.
Does the scheduler check schedules constantly, or on a fixed loop? The scheduler continuously loops (the interval is configurable via scheduler_heartbeat_sec), checking each active DAG’s next-due logical date against the current time — schedules aren’t evaluated by a separate cron daemon.
Summary
schedule accepts cron expressions, readable presets, timedelta objects, or custom Timetable logic for irregular cases — but the parameter itself is only half the picture. The other half is internalizing that a DAG run represents a completed data interval, which is why it executes at the interval’s end, not its start. Get that mental model right and most “my DAG didn’t run when I expected” confusion disappears.