# Airflow DAG Not Running on Schedule: Missed Runs, SLAs, Alerts

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/airflow-schedule-monitoring

> Why an Airflow DAG misses its schedule, when a daily DAG really runs, what replaced SLAs in Airflow 3, and how to get alerted when a DAG run never starts.

**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](https://github.com/apache/airflow/blob/3.3.2/task-sdk/src/airflow/sdk/definitions/dag.py#L342-L345)). 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](https://airflow.apache.org/docs/apache-airflow/3.3.2/release_notes.html#airflow-3-0-0-2025-04-22)).

### 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](https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#config-scheduler-create-cron-data-intervals)). Both kinds start runs at the same moment ([timetables](https://airflow.apache.org/docs/apache-airflow/3.3.2/authoring-and-scheduling/timetable.html#the-time-when-a-dag-run-is-triggered)). 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](https://airflow.apache.org/docs/apache-airflow/3.3.2/installation/upgrading_to_airflow3.html#breaking-changes)), 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](https://cronradar.com/blog/scheduled-job-silent-success) exist for. Switching an existing DAG from a trigger timetable to a data-interval one also skips one scheduled run ([timetables](https://airflow.apache.org/docs/apache-airflow/3.3.2/authoring-and-scheduling/timetable.html#switching-between-trigger-and-data-interval-timetables-on-an-existing-dag)).

### 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](https://airflow.apache.org/docs/apache-airflow/3.3.2/core-concepts/dag-run.html#catchup)). [Missed runs after downtime](https://cronradar.com/blog/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](https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#config-core-max-active-runs-per-dag)).

### 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](https://airflow.apache.org/docs/apache-airflow/3.3.2/core-concepts/overview.html#required-components)). In Airflow 3 the DAG processor is a separate process started with `airflow dag-processor`, even in development ([upgrade guide](https://airflow.apache.org/docs/apache-airflow/3.3.2/installation/upgrading_to_airflow3.html#step-7-changes-to-your-startup-scripts)); in Airflow 2.11 it ran inside the scheduler unless `standalone_dag_processor` was set ([2.11 configuration](https://airflow.apache.org/docs/apache-airflow/2.11.0/configurations-ref.html#standalone-dag-processor)).

### 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](https://airflow.apache.org/docs/apache-airflow/3.3.2/authoring-and-scheduling/timezone.html#cron-schedules)). [Cron daylight saving time](https://cronradar.com/blog/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](https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#config-core-dags-are-paused-at-creation); 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](https://github.com/apache/airflow/blob/3.3.2/task-sdk/src/airflow/sdk/definitions/dag.py#L375-L381)). The DAG processor looks for new files every `[dag_processor] refresh_interval`, 300 seconds by default ([configuration](https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#config-dag-processor-refresh-interval)), and Airflow's FAQ advises against a `datetime.now()` start date because the first run keeps moving with it ([FAQ](https://airflow.apache.org/docs/apache-airflow/3.3.2/faq.html#what-s-the-deal-with-start-date)). Tasks without a pool share `default_pool`, 128 slots, and wait once it's full ([pools](https://airflow.apache.org/docs/apache-airflow/3.3.2/administration-and-deployment/pools.html)); `parallelism` allows 32 running task instances per scheduler, and `max_active_tasks`, 16 by default, applies per run since Airflow 3 ([configuration](https://airflow.apache.org/docs/apache-airflow/3.3.2/configurations-ref.html#config-core-parallelism), [DAG source](https://github.com/apache/airflow/blob/3.3.2/task-sdk/src/airflow/sdk/definitions/dag.py#L375-L376)).

## 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](https://airflow.apache.org/docs/apache-airflow/2.11.0/core-concepts/tasks.html#slas)). Airflow 3.0 removed the feature ([upgrade guide](https://airflow.apache.org/docs/apache-airflow/3.3.2/installation/upgrading_to_airflow3.html#breaking-changes)), but 3.3.2 still accepts `sla` and `sla_miss_callback` and only warns that SLAs are gone ([operator](https://github.com/apache/airflow/blob/3.3.2/task-sdk/src/airflow/sdk/bases/operator.py#L1179-L1183), [DAG](https://github.com/apache/airflow/blob/3.3.2/task-sdk/src/airflow/sdk/definitions/dag.py#L707-L714)), 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](https://airflow.apache.org/docs/apache-airflow/3.3.2/howto/deadline-alerts.html)). 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:

```python
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](https://airflow.apache.org/docs/apache-airflow/3.3.2/howto/sla-to-deadlines.html#two-different-paradigms), [source](https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/serialization/definitions/dag.py#L686-L760)), 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](https://airflow.apache.org/docs/apache-airflow/3.3.2/administration-and-deployment/logging-monitoring/callbacks.html#callback-types)). 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 `reason` is `task_failure`, `timed_out` for a run past `dagrun_timeout`, or `all_tasks_deadlocked` ([processor](https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/dag_processing/processor.py#L365-L428), [DAG run](https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/models/dagrun.py#L1225-L1305), [scheduler](https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/jobs/scheduler_job_runner.py#L2853-L2906)). 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](https://github.com/apache/airflow/blob/3.3.2/task-sdk/src/airflow/sdk/execution_time/task_runner.py#L2301-L2364)). If that process dies without reporting, the scheduler fails the task and has the DAG processor run its failure callback ([scheduler](https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/jobs/scheduler_job_runner.py#L3519-L3530)).
- **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](https://airflow.apache.org/docs/apache-airflow/3.3.2/administration-and-deployment/logging-monitoring/check-health.html)), 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](https://github.com/apache/airflow/blob/3.3.2/airflow-core/pyproject.toml#L143)). This DAG sends `start` from its first task, `complete` from its last, and `fail` from a DAG-level failure callback:

```python
# 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`, so `start` and the terminal event pair up across task retries. Airflow allows 250 characters ([source](https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/models/base.py#L59)) and CronRadar 128, so longer manual run IDs are hashed.
- **The trigger rule** matters because a leaf task with `all_done` runs after failures, and if it succeeds the whole run is marked successful ([DAG runs](https://airflow.apache.org/docs/apache-airflow/3.3.2/core-concepts/dag-run.html#dag-run-status)).
- **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 `fail` once retries run out. Without them, a hang leaves a `start` with no finish, which CronRadar flags as stuck only if the next run's expected time plus grace passes with no check-in; with `max_active_runs=1` the hung run also blocks the next one, so that happens at the next due time.
- **`timeout=5`** limits connecting and each wait for data, not the whole request ([requests](https://requests.readthedocs.io/en/latest/user/quickstart/#timeouts)).

| 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](https://cronradar.com/blog/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](https://docs.cronradar.com/schedules)):

| 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](https://airflow.apache.org/docs/apache-airflow/3.3.2/authoring-and-scheduling/cron.html), Airflow's schedule description reads two restricted day fields as "or" ([source](https://github.com/apache/airflow/blob/3.3.2/airflow-core/src/airflow/timetables/_cron.py#L84-L119)), and the timedelta alignment is in the [timetable comparison](https://airflow.apache.org/docs/apache-airflow/3.3.2/authoring-and-scheduling/timetable.html#differences-between-the-cron-and-delta-data-interval-timetables).

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](https://cronradar.com/blog/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](https://cronradar.com/blog/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.
