Airflow DAG Not Running on Schedule: Missed Runs, SLAs, Alerts
Contents
When an Airflow DAG doesn't run on schedule, check whether it's paused, whether its file imported, whether it has a schedule and a past start_date, whether a concurrency limit is holding the run, and whether the scheduler and DAG processor are running. New DAGs start paused, an Airflow 3 DAG without a schedule never runs on its own, and a run that can't get a slot waits. None of this raises an error, and Airflow can't alert on a run it never created: SLAs were removed in Airflow 3.0, and their replacement, Deadline Alerts, only watches runs that exist, from inside the scheduler. A check-in from each run to a monitor outside Airflow catches the rest.
This guide covers Apache Airflow 3.3.2, released September 17, 2026 and the current stable release when checked on October 25, 2026, with Airflow 2.11 noted where defaults changed. Source links point to the 3.3.2 tag.
How Airflow Decides When a Run Starts#
A DAG's schedule accepts a cron expression or preset such as "@daily", a timedelta, a timetable object, or a list of assets, which trigger the DAG when another task updates them (DAG source). Assets were called datasets in Airflow 2. Airflow 3.0 removed schedule_interval and made None the default schedule, so a DAG without one runs only when triggered (3.0.0 release notes).
Trigger Time and Logical Date#
In Airflow 3, a cron string runs on CronTriggerTimetable: a run's logical date is the moment it's scheduled to start, and its data interval has no length. Airflow 2 used CronDataIntervalTimetable, where each run covers the period between two cron times, starts when that period ends, and is labeled with the period's start. The [scheduler] create_cron_data_intervals setting chooses between them and defaults to False in Airflow 3 and True in 2.x (configuration). Both kinds start runs at the same moment (timetables). For a @daily DAG in UTC:
| Airflow 3 default | Airflow 2 default | |
|---|---|---|
| Run starts | 2026-10-25 00:00 UTC | 2026-10-25 00:00 UTC |
logical_date |
2026-10-25 00:00 | 2026-10-24 00:00 |
| Data interval | none (start = end) | 2026-10-24 00:00 to 10-25 00:00 |
A monitor needs only the start time, which is the cron expression itself: 0 0 * * *. What changes is the date your tasks see. Templated values such as ds derive from logical_date (upgrade guide), so a task that loads the day in {{ ds }} loads a different day after an upgrade that switches timetables, and still finishes on time and successfully. That's the failure checks on what a run produced exist for. Switching an existing DAG from a trigger timetable to a data-interval one also skips one scheduled run (timetables).
Catchup, start_date, and max_active_runs#
catchup decides what happens to schedule points that pass while a DAG is paused or the scheduler is down. Its default, [scheduler] catchup_by_default, is False in Airflow 3 and was True in 2.x. With it off, the scheduler creates a run only for the latest interval; with catchup=True, it creates one for every interval that hasn't run (DAG runs). Missed runs after downtime compares this with other schedulers.
No run is scheduled before start_date. max_active_runs caps the DAG's active runs, 16 by default from [core] max_active_runs_per_dag, and at the cap the scheduler creates no new runs (configuration).
The Components That Have to Be Running#
The scheduler creates DAG runs and hands ready tasks to the executor, which runs inside the scheduler process. The DAG processor parses DAG files into the metadata database, where the scheduler reads them (architecture). In Airflow 3 the DAG processor is a separate process started with airflow dag-processor, even in development (upgrade guide); in Airflow 2.11 it ran inside the scheduler unless standalone_dag_processor was set (2.11 configuration).
Time Zones#
Airflow stores times in UTC and applies [core] default_timezone, utc unless changed, to naive datetimes. A DAG takes its time zone from a pendulum start_date, and cron schedules follow that zone's daylight saving time: a 0 0 * * * DAG in US/Eastern runs at 04:00 UTC in summer and 05:00 UTC in winter. Airflow's docs recommend authoring DAGs in UTC where you can (time zones). Cron daylight saving time covers the transition nights.
Why an Airflow DAG Didn't Run on Schedule#
| What you see | Likely cause | Check |
|---|---|---|
| DAG listed, no runs, toggle off | Paused: [core] dags_are_paused_at_creation defaults to True |
airflow dags unpause nightly_orders_etl |
| DAG missing from the UI | Import error, or the DAG processor isn't running | airflow dags list-import-errors, airflow jobs check --job-type DagProcessorJob |
| DAG listed, never scheduled | No schedule, or start_date in the future |
airflow dags next-execution nightly_orders_etl --field run_after |
| First run never arrives | start_date=datetime.now() |
Use a fixed start_date |
| No DAG gets new runs | Scheduler down or stuck | airflow jobs check --job-type SchedulerJob |
| Run created, tasks don't start | Pool full, max_active_tasks, parallelism, or no worker |
Task instance details and pool slots in the UI |
| Previous run still going, next one missing | max_active_runs reached |
Runs in the running state |
| DAG stopped after repeated failures | max_consecutive_failed_dag_runs paused it |
Paused state and recent run history |
| A run missing after an outage | catchup=False created only the latest |
Backfill the gap if the data needs it |
The pause default is in the configuration reference; a DAG can opt out with is_paused_upon_creation=False. The experimental max_consecutive_failed_dag_runs is 0, never, unless someone set it (DAG source). The DAG processor looks for new files every [dag_processor] refresh_interval, 300 seconds by default (configuration), and Airflow's FAQ advises against a datetime.now() start date because the first run keeps moving with it (FAQ). Tasks without a pool share default_pool, 128 slots, and wait once it's full (pools); parallelism allows 32 running task instances per scheduler, and max_active_tasks, 16 by default, applies per run since Airflow 3 (configuration, DAG source).
SLAs in Airflow 2, Deadline Alerts in Airflow 3#
Airflow 2 had task SLAs: sla=timedelta(...) on a task, misses listed under SLA Misses in the UI and emailed, and an optional DAG-level sla_miss_callback. Tasks over their SLA kept running, and manually triggered runs and event-driven DAGs weren't checked (Airflow 2.11 SLAs). Airflow 3.0 removed the feature (upgrade guide), but 3.3.2 still accepts sla and sla_miss_callback and only warns that SLAs are gone (operator, DAG), so a migrated DAG parses and never alerts.
Deadline Alerts replaced them in Airflow 3.1 and are still marked experimental in the 3.3.2 docs (Deadline Alerts). A DeadlineAlert adds an interval to a reference point (DAGRUN_QUEUED_AT, DAGRUN_LOGICAL_DATE, FIXED_DATETIME, or AVERAGE_RUNTIME) and runs a callback if the DAG run hasn't finished by then. An AsyncCallback runs in the triggerer; a SyncCallback, added in 3.2, is sent to the executor like a task. With the Slack provider installed, on Airflow 3.2 or later:
from datetime import timedelta
from airflow.providers.slack.notifications.slack_webhook import SlackWebhookNotifier
from airflow.providers.standard.operators.empty import EmptyOperator
from airflow.sdk import DAG, AsyncCallback, DeadlineAlert, DeadlineReference
with DAG(
dag_id="nightly_orders_deadline",
schedule="0 2 * * *",
deadline=DeadlineAlert(
reference=DeadlineReference.DAGRUN_QUEUED_AT,
interval=timedelta(hours=1),
callback=AsyncCallback(
SlackWebhookNotifier,
kwargs={"text": "{{ dag_run.dag_id }} missed its deadline at {{ deadline.deadline_time }}"},
),
),
):
EmptyOperator(task_id="load_orders")On 3.1, import AsyncCallback from airflow.sdk.definitions.deadline. Two limits matter for scheduling problems. First, Airflow computes a deadline when it creates the DAG run, and the scheduler loop checks for passed deadlines (migration guide, source), so a run that was never created has no deadline and a stopped scheduler checks none. Second, DAGRUN_LOGICAL_DATE counts from the logical date, which on a data-interval timetable is a full period before the run starts; use DAGRUN_QUEUED_AT there.
Callbacks Only Cover Runs That Exist#
on_success_callback and on_failure_callback can be set on the DAG or on tasks; tasks also have retry, execute, and skipped callbacks (callbacks). Where they run decides what they need:
- DAG-level callbacks run in the DAG processor. When a run ends, the scheduler sends it a request; a failed run's
reasonistask_failure,timed_outfor a run pastdagrun_timeout, orall_tasks_deadlocked(processor, DAG run, scheduler). Their errors go to DAG processor logs, not task logs. - Task-level callbacks run in the task's process on the worker, after the task finishes (task runner). If that process dies without reporting, the scheduler fails the task and has the DAG processor run its failure callback (scheduler).
- Neither fires for a state you set in the UI or CLI.
Why an External Check Still Helps#
If the scheduler stops, no runs are created, so no task, callback, or deadline runs, and nothing inside Airflow reports it. Airflow's /api/v2/monitor/health endpoint and airflow jobs check show whether the scheduler, DAG processor, and triggerer are heartbeating (health checks), but something outside has to poll them, and a healthy scheduler says nothing about a paused DAG or one that failed to import. A schedule monitor works from the other side: each run reports, and the monitor, running outside Airflow, alerts when an expected report doesn't arrive, whatever stopped it.
Report Each DAG Run to CronRadar#
There's no CronRadar SDK for Airflow, the Python SDK isn't installable (checked September 20, 2026), and nothing discovers DAGs automatically, so each DAG reports with its own HTTP check-ins. Airflow's core package already depends on requests (pyproject.toml). This DAG sends start from its first task, complete from its last, and fail from a DAG-level failure callback:
# dags/nightly_orders_etl.py
import hashlib
import logging
import os
from datetime import timedelta
import pendulum
import requests
from airflow.providers.standard.operators.bash import BashOperator
from airflow.sdk import dag, task
log = logging.getLogger(__name__)
# The DAG runs in UTC, so Airflow and CronRadar read this expression the same way.
SCHEDULE = "0 2 * * *"
CHECK_IN_URL = "https://cron.life/api/v1/ping/nightly-orders-etl"
def check_in(event, airflow_run_id, message=None):
"""Report one event to CronRadar. Never raises into Airflow."""
try:
run_id = airflow_run_id
if len(run_id) > 128: # CronRadar's limit; Airflow allows 250
run_id = hashlib.sha256(run_id.encode()).hexdigest()
params = {"schedule": SCHEDULE, "gracePeriod": 900, "run_id": run_id}
if message:
params["message"] = message[:1000]
response = requests.post(
f"{CHECK_IN_URL}/{event}",
params=params,
auth=(os.environ["CRONRADAR_API_KEY"], ""),
timeout=5,
)
response.raise_for_status()
except Exception as error:
log.warning("CronRadar %s not recorded (%s)", event, type(error).__name__)
def report_failed_run(context):
"""DAG-level failure callback. The DAG processor runs it, not a worker."""
ti = context.get("ti")
task_note = f", task {ti.task_id}" if ti is not None else ""
check_in("fail", context["run_id"], f"Airflow run failed ({context.get('reason')}{task_note})")
@dag(
schedule=SCHEDULE,
start_date=pendulum.datetime(2026, 10, 1, tz="UTC"),
catchup=False,
max_active_runs=1,
dagrun_timeout=timedelta(hours=3),
on_failure_callback=report_failed_run,
default_args={"retries": 2, "retry_delay": timedelta(minutes=5)},
)
def nightly_orders_etl():
@task
def report_start(**context):
check_in("start", context["run_id"])
load_orders = BashOperator(
task_id="load_orders",
bash_command="python /opt/etl/load_orders.py",
execution_timeout=timedelta(minutes=45),
)
# all_success is the default; it's written out so nobody switches it to all_done.
@task(trigger_rule="all_success")
def report_complete(**context):
check_in("complete", context["run_id"])
report_start() >> load_orders >> report_complete()
nightly_orders_etl()- The run ID is the DAG run's own, such as
scheduled__2026-10-25T02:00:00+00:00, sostartand the terminal event pair up across task retries. Airflow allows 250 characters (source) and CronRadar 128, so longer manual run IDs are hashed. - The trigger rule matters because a leaf task with
all_doneruns after failures, and if it succeeds the whole run is marked successful (DAG runs). - The key has to be in the environment of the workers and the DAG processor, which runs the failure callback. Use one CronRadar application and key per Airflow environment. An existing failure callback can stay: pass a list,
[notify_team, report_failed_run]. - Timeouts turn hangs into
failonce retries run out. Without them, a hang leaves astartwith no finish, which CronRadar flags as stuck only if the next run's expected time plus grace passes with no check-in; withmax_active_runs=1the hung run also blocks the next one, so that happens at the next due time. timeout=5limits connecting and each wait for data, not the whole request (requests).
| How the run ends | CronRadar receives | Monitor |
|---|---|---|
| All tasks succeed | start, complete |
healthy, duration recorded |
load_orders fails after its retries |
start, fail with task_failure and the task's ID |
critical immediately |
Run passes dagrun_timeout |
start, fail with timed_out |
critical immediately |
| No run: paused, not parsed, scheduler down, runs at cap | nothing | warning at 02:00, critical after grace |
| Tasks wait for a slot past the grace period | a late start |
critical, recovers when the run completes |
| The run fails and the DAG processor never runs the callback | start only |
not flagged if the next run starts on time |
Alerts go to email, Slack, a webhook, Discord, or PagerDuty. Because every check-in carries the schedule, the first one creates the monitor and a deleted monitor comes back on the next run. But new DAGs start paused, and a monitor created by its first check-in can't flag a DAG that never runs. Create the monitor in the CronRadar dashboard before deploying, with the same key, schedule, and grace: it stays pending, and CronRadar sends a never-executed alert if the first expected run plus grace passes with no report.
A run that loads zero rows still sends complete. To catch that, report counts as bounded outcomes; ETL job monitoring shows which numbers to send.
Choose the Schedule and Grace Period to Send#
Send the time the run should start, in UTC, as a five-field expression (schedule syntax):
Airflow schedule |
CronRadar schedule |
Why |
|---|---|---|
"0 2 * * *" in a UTC DAG |
0 2 * * * |
Same start time under both timetables |
"@daily", "@hourly" |
0 0 * * *, 0 * * * * |
CronRadar doesn't accept presets |
"0 9 * * MON#1" |
0 9 1-7 * 1 |
# is a croniter extension; CronRadar requires both day fields to match |
"0 9 1 * 1" |
No single equivalent | Airflow runs on the 1st or any Monday; CronRadar would expect both |
"0 6 * * *" with tz="America/New_York" |
0 10 * * *, grace of 3600 plus start delay |
06:00 there is 10:00 UTC in summer, 11:00 UTC in winter |
timedelta(hours=6) |
Switch to a cron string | With catchup off, it aligns to when the DAG was enabled, not the clock |
Assets, EventsTimetable |
None | There's no time at which a run is due |
Sources: the # syntax is on Airflow's cron page, Airflow's schedule description reads two restricted day fields as "or" (source), and the timedelta alignment is in the timetable comparison.
An asset-triggered DAG runs whenever an upstream task updates the asset, so no time says a run is due, and a CronRadar monitor needs a cron schedule. It can't be represented. Monitor the time-scheduled DAG that produces the asset, or a scheduled job that checks the data's freshness and sends its own heartbeat, as ETL job monitoring describes.
The grace period has to cover the delay before report_start runs, while the scheduler creates the run and a worker picks up the task. Compare recent runs' scheduled times with report_start's start times in the UI and take the worst case plus a margin. A pod per task or a full pool at 02:00 can take minutes, so CronRadar's 90-second default may be too short; cron grace period works through the arithmetic. schedule and gracePeriod apply only when a check-in creates the monitor; change them later in its settings. Then compare airflow dags next-execution nightly_orders_etl --field run_after -n 3 with CronRadar's next expected run, especially for a DAG outside UTC.
Catch a Stopped Scheduler Sooner#
With one monitor per DAG, a stopped scheduler shows up as each DAG goes overdue, and a daily DAG may not be due for hours. A canary DAG closes the gap: one task on */10 * * * * that posts a heartbeat to https://cron.life/api/v1/ping/airflow-canary, with no event suffix, schedule=*/10 * * * *, and the same requests options. Give the DAG is_paused_upon_creation=False, catchup=False, and max_active_runs=1. If the scheduler or the workers stop, the canary misses a check-in and goes critical after its grace period, and the per-DAG monitors show which pipelines missed runs.
Frequently Asked Questions#
Why is my Airflow DAG not running on schedule?#
Check whether it's paused first: new DAGs start paused because dags_are_paused_at_creation defaults to True. Then run airflow dags list-import-errors, and confirm the DAG has a schedule, which Airflow 3 doesn't set by default, and a fixed start_date in the past. After that, look for a previous run holding max_active_runs or tasks waiting for pool slots. If no DAG is running, check the scheduler and the DAG processor.
Does Airflow 3 still have SLAs?#
No. Airflow 3.0 removed SLAs, and the sla and sla_miss_callback arguments now only produce a warning. Deadline Alerts, added in 3.1 and still experimental in 3.3, run a callback when a DAG run hasn't finished by a deadline measured from when it was queued, its logical date, a fixed time, or its average runtime. They only cover runs that were created, while the scheduler is running.
What does catchup do in Airflow?#
It decides whether the scheduler creates runs for schedule points missed while a DAG was paused or the scheduler was down. With catchup=True it creates one run per missed interval; with catchup=False, the default since Airflow 3.0, it creates only the latest, and nothing in Airflow flags the skipped ones.
When does a daily Airflow DAG actually run?#
At 00:00 in the DAG's time zone. In Airflow 2, and in Airflow 3 with create_cron_data_intervals=True, the run that starts at 00:00 on October 25 has a logical date of October 24, because it covers that day; Airflow 3's default timetable labels it October 25. The schedule a monitor needs is the same either way: 0 0 * * * for a DAG in UTC.
How do I get alerted if the Airflow scheduler stops?#
Use a monitor outside Airflow that notices silence, because a stopped scheduler runs no callbacks or deadlines. Check-ins from each DAG run alert when a run doesn't report, and a canary DAG checking in every ten minutes turns a stopped scheduler into a missed check-in within ten minutes plus its grace period. Airflow's health endpoint and airflow jobs check --job-type SchedulerJob report the scheduler's heartbeat, but need an external poller.
Put this guide to work
Connect your Python job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
Python SDK connections are temporarily unavailable. Use manual HTTP reporting below. This setup does not discover jobs automatically.