Data Engineering  /  Airflow

🌬️ Apache Airflow Guide 1 of 3 18 guides · updated 2026

Workflow orchestration with Airflow — DAGs, operators, scheduling, executors, and the production practices that keep pipelines reliable.

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 AM
schedule="*/15 * * * *" # every 15 minutes
schedule="0 9 * * 1-5" # 9 AM, Monday through Friday

Presets — 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 schedule

timedelta objects — a fixed interval from the last run, rather than a fixed clock time:

from datetime import timedelta
schedule=timedelta(hours=6) # every 6 hours, relative to start_date

The 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.