Skip to content

Calendar view computes planned runs for cron timetables in UTC, ignoring the DAG's configured timezone #71234

Description

@simon-hoon

Under which category would you file this issue?

Airflow Core

Apache Airflow version

3.3.0

What happened and how to reproduce it?

For a DAG using a plain cron-string schedule (e.g. "0 8 * * *") on an Airflow instance whose [core] default_timezone is not UTC (e.g. Asia/Seoul, UTC+9), the Calendar view's "planned" (future, not-yet-run) cells are shown at the wrong hour — offset by the UTC/local difference.

Concretely, with AIRFLOW__CORE__DEFAULT_TIMEZONE=Asia/Seoul and a DAG scheduled "0 8 * * *":

  • The DAG actually fires at 08:00 KST, confirmed by airflow dags next-execution and by the DAG detail page's "Next Run" / "Last Run" fields (both correctly show 08:00:00).
  • Real, already-executed runs are shown correctly on the Calendar at the 08:00 row.
  • Future "planned" cells for the same DAG are drawn at the 17:00 row instead (08:00 + 9h) — i.e. exactly a UTC-vs-KST offset.

Historical (already-happened) runs are unaffected because they're read straight from DagRun rows already computed correctly by the scheduler. Only the projected/planned rows — computed on the fly by the Calendar API — are wrong.

How to reproduce:

Via the UI:

  1. Deploy Airflow with AIRFLOW__CORE__DEFAULT_TIMEZONE=Asia/Seoul (any non-UTC zone works; easiest to see with a schedule hour close to local midnight).
  2. Create a DAG with schedule="0 8 * * *" (no explicit timetable timezone override — it inherits the core default).
  3. Let it run at least once, or just open the DAG's Calendar tab.
  4. Compare the DAG detail page's "Next Run"/"Last Run" (correctly 08:00) against the Calendar tab's planned cells for the same DAG (shown at 17:00).

Minimal Python repro (no webserver needed) — confirms the bad value is computed by the service itself, not introduced or fixed anywhere downstream:

from croniter import croniter
from datetime import datetime
import pendulum
from airflow.timetables.trigger import CronTriggerTimetable

tt = CronTriggerTimetable("0 8 * * *", timezone="Asia/Seoul")
last_end_utc = pendulum.datetime(2026, 8, 5, 23, 0, 0, tz="UTC")  # = 2026-08-06 08:00 KST

# (A) what CalendarService._calculate_cron_planned_runs does:
next_planned = next(croniter(tt._expression, start_time=last_end_utc, ret_type=datetime))
print(next_planned, "->", next_planned.astimezone(pendulum.timezone("Asia/Seoul")))
# 2026-08-06 08:00:00+00:00 -> 2026-08-06 17:00:00+09:00   (WRONG)

# (B) what the real scheduler uses (CronMixin._get_next):
next_real = tt._get_next(last_end_utc)
print(next_real, "->", next_real.in_timezone("Asia/Seoul"))
# 2026-08-06 23:00:00+00:00 -> 2026-08-07 08:00:00+09:00   (matches `airflow dags next-execution`)

I also confirmed this end-to-end by calling CalendarService.get_calendar_data() (the exact function GET /ui/calendar/{dag_id} calls) against a live instance's real DagRun history for a schedule="0 8 * * *" DAG: the one real, already-executed run came back correct (08:00 KST), while every planned/future occurrence came back exactly 9 hours later. This is the value returned by the API itself — nothing downstream corrects it. (Screenshot attached below, from a fresh local demo instance — no relation to the code snippets above.)

Image

What you think should happen instead?

Planned/future run cells in the Calendar view should be computed in the DAG's configured timetable timezone, the same way the real scheduler computes actual runs, so they land in the same hour/day as the runs that will actually execute.

Root cause: CalendarService._calculate_cron_planned_runs() in airflow-core/src/airflow/api_fastapi/core_api/services/ui/calendar.py:

def _calculate_cron_planned_runs(
    self, dag, last_data_interval, year, date_filter, granularity,
):
    """Calculate planned runs for cron-based timetables."""
    dates: dict[datetime, int] = collections.Counter()

    dates_iter: Iterator[datetime | None] = croniter(
        cast("CronMixin", dag.timetable)._expression,
        start_time=last_data_interval.end,
        ret_type=datetime,
    )
    ...

last_data_interval.end is a UTC-aware datetime. croniter's documented contract (its README: "Be sure to init your croniter instance with a TZ aware datetime for this to work!") is that the caller localizes start_time to whatever timezone the cron fields should be matched against — croniter just reads the tzinfo off it (self.tzinfo = start_time.tzinfo in set_current). _calculate_cron_planned_runs never does that localization, so "0 8 * * *" gets matched against UTC wall-clock instead of dag.timetable's configured Asia/Seoul (→ 08:00 UTC = 17:00 KST). This is croniter behaving exactly as documented — the bug is entirely on the caller's side.

Contrast with how the real scheduler computes the next run — CronMixin._get_next() in airflow-core/src/airflow/timetables/_cron.py, which does the localize/delocalize correctly:

def _get_next(self, current: DateTime) -> DateTime:
    naive = make_naive(current, self._timezone)   # UTC -> local naive wall-clock
    ...                                             # croniter matches on local wall-clock
    return convert_to_utc(make_aware(scheduled, self._timezone))  # local -> UTC

Notably, the sibling function in the same file, _calculate_timetable_planned_runs() (used for non-cron/partitioned timetables), does this correctly via dag.timetable.next_dagrun_info_v2(...). Only the cron-specific fast path bypasses it. Still present, unchanged, on main (diffed byte-for-byte against the installed 3.3.0 function body).

Related prior art (same class of bug, different surfaces):

Suggested fix:

Localize start_time into the timetable's own timezone before constructing croniter in _calculate_cron_planned_runs (mirroring CronMixin._get_next), converting each result back to UTC — reuses the same private-attribute access pattern (cast("CronMixin", dag.timetable)._expression) already used in that function, just also reading ._timezone.

(Originally also considered routing cron timetables through the same next_dagrun_info_v2-based path as _calculate_timetable_planned_runs. Ruled out: that path's catchup=False branch anchors to utcnow(), which breaks the sequential "planned" projection when the last recorded run is far from real wall-clock time — verified this regresses 3 existing tests.)

Verified locally (uv sync --package apache-airflow-core + pytest on test_calendar.py): 19 existing tests still pass + 1 new regression test for a non-UTC cron timetable — 20/20 passing.

Operating System

Not specific to an OS — reproduced on macOS (local venv) and confirmed against a Kubernetes/Helm-chart deployment (Debian-based image).

Deployment

Virtualenv installation

Apache Airflow Provider(s)

No response

Versions of Apache Airflow Providers

No response

Official Helm Chart version

Not Applicable

Kubernetes Version

No response

Helm Chart configuration

No response

Docker Image customizations

No response

Anything else?

Deterministic, not intermittent — occurs for any cron-scheduled DAG on a non-UTC default_timezone, every time the Calendar view computes planned/future runs.

Are you willing to submit PR?

  • Yes I am willing to submit a PR!

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:APIAirflow's REST/HTTP APIarea:UIRelated to UI/UX. For Frontend Developers.kind:bugThis is a clearly a bugneeds-triagelabel for new issues that we didn't triage yet

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions