Time zone casing is platform-dependent¶
zoneinfo.ZoneInfo does not normalize time zone IDs.
It looks up the given string as a file path, so casing follows the underlying
filesystem. For example, this typically succeeds on macOS but fails on Linux:
ZoneInfo("europe/amsterdam")
This makes validation non-portable. A value accepted during development can
be stored in a database or pickle, then fail with
ZoneInfoNotFoundError in production. When lookup succeeds,
ZoneInfo.key also preserves the caller’s
casing instead of returning the database spelling.
Even more surprisingly, two spellings can change arithmetic on a case-insensitive filesystem:
canonical = ZoneInfo("Europe/Amsterdam")
lowercase = ZoneInfo("europe/amsterdam")
canonical is lowercase
# False—even though they loaded the same time zone file
before = datetime(2024, 3, 31, 1, 30, tzinfo=canonical)
after1 = datetime(2024, 3, 31, 3, 30, tzinfo=canonical)
after2 = datetime(2024, 3, 31, 3, 30, tzinfo=lowercase)
after1 - before
# datetime.timedelta(seconds=7200)
after2 - before
# datetime.timedelta(seconds=3600)
This happens because datetime switches subtraction rules based on whether
the two tzinfo objects are identical. See + ignores DST.
The behavior is discussed in CPython issue #115022. The IANA reference implementation is case-sensitive, although its naming rules forbid IDs that differ only in case.
How whenever solves this¶
Whenever matches ASCII casing portably and exposes the database spelling:
>>> ZonedDateTime(2024, 3, 31, 3, 30, tz="europe/amsterdam")
ZonedDateTime("2024-03-31 03:30:00+02:00[Europe/Amsterdam]")
All casing variants also share one cached time zone definition, so casing cannot change comparison or arithmetic behavior.