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:
- 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).
- Create a DAG with
schedule="0 8 * * *" (no explicit timetable timezone override — it inherits the core default).
- Let it run at least once, or just open the DAG's Calendar tab.
- 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.)
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?
Code of Conduct
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_timezoneis 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/Seouland a DAG scheduled"0 8 * * *":airflow dags next-executionand by the DAG detail page's "Next Run" / "Last Run" fields (both correctly show08:00:00).Historical (already-happened) runs are unaffected because they're read straight from
DagRunrows 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:
AIRFLOW__CORE__DEFAULT_TIMEZONE=Asia/Seoul(any non-UTC zone works; easiest to see with a schedule hour close to local midnight).schedule="0 8 * * *"(no explicit timetable timezone override — it inherits the core default).08:00) against the Calendar tab's planned cells for the same DAG (shown at17:00).Minimal Python repro (no webserver needed) — confirms the bad value is computed by the service itself, not introduced or fixed anywhere downstream:
I also confirmed this end-to-end by calling
CalendarService.get_calendar_data()(the exact functionGET /ui/calendar/{dag_id}calls) against a live instance's realDagRunhistory for aschedule="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.)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()inairflow-core/src/airflow/api_fastapi/core_api/services/ui/calendar.py:last_data_interval.endis a UTC-awaredatetime.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 localizesstart_timeto whatever timezone the cron fields should be matched against — croniter just reads the tzinfo off it (self.tzinfo = start_time.tzinfoinset_current)._calculate_cron_planned_runsnever does that localization, so"0 8 * * *"gets matched against UTC wall-clock instead ofdag.timetable's configuredAsia/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()inairflow-core/src/airflow/timetables/_cron.py, which does the localize/delocalize correctly:Notably, the sibling function in the same file,
_calculate_timetable_planned_runs()(used for non-cron/partitioned timetables), does this correctly viadag.timetable.next_dagrun_info_v2(...). Only the cron-specific fast path bypasses it. Still present, unchanged, onmain(diffed byte-for-byte against the installed 3.3.0 function body).Related prior art (same class of bug, different surfaces):
airflow dags clearclearing the wrong day for non-UTC partitioned timetables #67717 (merged) — the same pattern (comparing UTC-parsed dates directly against a non-UTC timetable's dates) forairflow dags clear, fixed via a newTimetable.resolve_day_bound().next_dagrun_info_v2path for non-cron/partitioned timetables in this samecalendar.pyfile; the cron fast path was left behind.Suggested fix:
Localize
start_timeinto the timetable's own timezone before constructingcroniterin_calculate_cron_planned_runs(mirroringCronMixin._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'scatchup=Falsebranch anchors toutcnow(), 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 ontest_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?
Code of Conduct