simon-hoon opened a new issue, #71234:
URL: https://github.com/apache/airflow/issues/71234
### 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:
```python
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.)
<img width="1080" height="700" alt="Image"
src="https://github.com/user-attachments/assets/6f55add7-c21c-43a9-a8e8-01a15abc7e33"
/>
### 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`:
```python
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:
```python
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):
- #67477 (fixed) — a different layer: the React frontend ignored the user's
selected UI timezone. Since it's fixed, the frontend now faithfully renders
whatever value the API sends — which is why this backend bug is visible
end-to-end.
- #67717 (merged) — the same pattern (comparing UTC-parsed dates directly
against a non-UTC timetable's dates) for `airflow dags clear`, fixed via a new
`Timetable.resolve_day_bound()`.
- #63631 — added the correct, timezone-aware `next_dagrun_info_v2` path for
non-cron/partitioned timetables in this same `calendar.py` file; the cron fast
path was left behind.
Suggested fix — either:
1. Route cron timetables through the same `next_dagrun_info_v2`-based path
as `_calculate_timetable_planned_runs` (simplest, reuses already-correct logic,
at some perf cost vs. croniter's fast iteration), or
2. Keep the croniter fast path, but localize `start_time` into the
timetable's own timezone before constructing `croniter` (mirroring
`CronMixin._get_next`), converting results back to UTC afterward. This needs
read access to the timetable's timezone from `calendar.py` —
`CronMixin._timezone` is currently private; a small public accessor (in the
spirit of the `resolve_day_bound()` added in #67717) would do it cleanly.
### 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?
- [x] Yes I am willing to submit a PR!
### Code of Conduct
- [x] I agree to follow this project's [Code of
Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]