The job ran fine on my laptop for weeks, then died the moment I moved it onto a clean server. The traceback pointed at zoneinfo._tzpath, and I did what most of us do first: I blamed the library. Why would ZoneInfo raise ZoneInfoNotFoundError for a name every laptop seems to know by heart? This is the 48-hour field notebook I wish I had opened on hour one.
Hour 0–6: I treated a missing file as a logic bug
I started from a scheduler that stamps a deadline in America/New_York and compares it with now. The comparison looked innocent, and the unit tests stayed green on my machine. I even reran them after a reboot, because stale processes have lied to me before. Have you ever defended a function so hard that you forgot to ask whether the import actually finished?
Here is the shape of the code I was defending, trimmed to the failing path:
from datetime import datetime
from zoneinfo import ZoneInfo
NYC = ZoneInfo("America/New_York")
def deadline_passed(ts: datetime) -> bool:
now = datetime.now(NYC)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=NYC)
return now >= ts
On the laptop that looked reasonable. On the server it never reached the comparison, because constructing NYC exploded during import. I pasted the traceback into a chat window and asked for a fix. The first suggestion was to catch ZoneInfoNotFoundError and fall back to naive datetime.now(). That would have silenced the crash and made daylight-saving time completely fictional. The second suggestion was pip install pytz, which still did not explain two interpreters disagreeing.
Hour 6–18: I chased versions, then DST, then remote DNS
I wasted a long stretch confirming both sides ran the same Python minor version. They did. I then convinced myself DST fold logic was involved, because New York is a famous source of off-by-one-hour bugs. I wrote extra assertions around fold and still never hit them. I also poked at DNS, because the box was remote and I get superstitious when localhost stops being the runtime.
None of that had any relationship to zoneinfo. A scary import error gives your brain permission to audit the entire universe, doesn't it? What I should have run in the first ten minutes is boring, local, and decisive:
python -c "from zoneinfo import ZoneInfo; ZoneInfo('America/New_York')"
python -c "import zoneinfo; print(zoneinfo.TZPATH)"
ls /usr/share/zoneinfo/America 2>/dev/null | head
dpkg -l tzdata 2>/dev/null || rpm -q tzdata 2>/dev/null
python -c "import tzdata; print(tzdata.__file__)"
On the laptop, TZPATH pointed at a populated /usr/share/zoneinfo. On the clean server that directory was missing or empty, and the PyPI tzdata package was not installed either. The IANA database was never on the box. ZoneInfo was doing its job, and I had been arguing with a missing directory.
The contract I had never actually read
CPython's zoneinfo is not a timezone encyclopedia that ships inside libpython. It is a reader. It looks at system zoneinfo files first, then at the tzdata package on PyPI if you installed it. No files means no America/New_York. On a stripped image it often means no UTC file either.
That last part still surprises people. datetime.timezone.utc is a stdlib constant and does not need IANA data. ZoneInfo("UTC") is a named-zone lookup and can fail on a slim image. Which form did the assistant generate? The named one, because it reads more correct in a vacuum. Related trap, still current on 3.12 and 3.13: models still emit datetime.utcnow(), which returns a naive datetime and is deprecated. Mixing that naive value with an aware ZoneInfo clock is a second crash you only see after you fix the first one.
Artifact: a fail-fast probe and a decision table
I now keep a tiny probe next to any job that touches timezones. It is meant to fail in CI, not during the first hour of a deploy. Label this as a checklist I actually rerun, not as a benchmark of any vendor image.
"""tzprobe.py — fail fast when IANA data or UTC semantics are missing."""
from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def must_load(key: str) -> ZoneInfo:
try:
return ZoneInfo(key)
except ZoneInfoNotFoundError as exc:
raise SystemExit(
f"missing tzdata for {key!r}: install OS package tzdata "
f"or `pip install tzdata`. original={exc}"
) from exc
def main() -> None:
utc_builtin = datetime.now(timezone.utc)
utc_named = datetime.now(must_load("UTC"))
nyc = datetime.now(must_load("America/New_York"))
if utc_builtin.tzinfo is None:
raise SystemExit("timezone.utc produced a naive datetime; aborting")
delta = abs((utc_builtin - utc_named).total_seconds())
if delta > 1:
raise SystemExit(f"UTC builtin and ZoneInfo('UTC') drifted by {delta}s")
print("builtin UTC", utc_builtin.isoformat())
print("named UTC ", utc_named.isoformat())
print("NYC ", nyc.isoformat())
print("ok")
if __name__ == "__main__":
main()
Run it three ways before you trust a deploy:
# 1) your laptop, should pass
python tzprobe.py
# 2) a slim container, often fails without tzdata
docker run --rm -v "$PWD:/src" -w /src python:3.13-slim python tzprobe.py
# 3) same image after an explicit dependency
docker run --rm -v "$PWD:/src" -w /src python:3.13-slim \
bash -c "pip install -q tzdata && python tzprobe.py"
I am not claiming one cloud image layout here, because distros disagree about default packages. The point is the probe, not a screenshot of somebody else's /usr/share. If step two fails and step three passes, you do not have a datetime algorithm problem. You have a packaging problem.
Decision table I actually follow now
- Need now in UTC for APIs, logs, or JWT
exp? Usedatetime.now(timezone.utc)and skipZoneInfo. - Need a civil timezone with DST rules, like
America/New_York? UseZoneInfo, and depend on OStzdataor PyPItzdata. - Parsing an offset like
+00:00from a payload? Usedatetime.fromisoformatand do not invent a zone name. - Model suggests
pytzfor new code? Decline it unless an old library already imports it. - Model wraps the import in
except Exception? Delete that branch. Missing IANA data is a deploy bug.
A pytest that would have saved the second day
import pytest
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
REQUIRED = ("UTC", "America/New_York")
@pytest.mark.parametrize("key", REQUIRED)
def test_iana_zone_is_available(key):
try:
zone = ZoneInfo(key)
except ZoneInfoNotFoundError as exc:
pytest.fail(f"{key} missing on this runner: {exc}")
assert str(zone) == key
Put that on the same image you deploy, not on the image your laptop resembles. A green test on a full desktop distro does not certify a slim runtime. I keep repeating that because I ignored it for a day and a half.
Where a free model and a free server actually helped
I needed a second machine that did not inherit my laptop's package set. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free server option as that second box, and free model access as a hypothesis generator while the traceback was still opaque. The model was fast at listing possible causes. It was also confidently wrong about half of them, including upgrade zoneinfo as if I had pinned a PyPI library poorly.
The useful workflow stayed narrow. Paste the exact exception, ask for commands that inspect TZPATH, then run those commands on a clean server instead of on the machine where the bug cannot happen. Would I let the model patch the scheduler after it offered naive datetime.now() as a fallback? Not again. The free server mattered more than the prose, because it reproduced the missing files. The model mattered only after I already had a failing command.
What broke, and what I would repeat
What broke was my mental model, not zoneinfo. I treated stdlib timezones like they were batteries-included data, the same way people treat CA certificates until a slim image drops them. Tests that run only on developer laptops will keep lying about this class of failure. The 48 hours were not a mystery about clocks. They were a mystery about which files an image bothers to ship.
What I would repeat:
- Copy the traceback, then immediately print
zoneinfo.TZPATHon both machines. - Distinguish
timezone.utcfromZoneInfo("UTC")before changing business logic. - Add
tzprobe.pyand the parametrized pytest to the deploy image. - Pin either the OS
tzdatapackage or the PyPItzdatapackage besiderequirements.txt. - Ask a model for inspection commands, not for a
try/exceptthat hides the outage.
What I would not repeat is spending a night on DST fold diagrams while the import still crashed. If the constructor cannot load the zone, you do not have a datetime bug yet. You have a packaging bug, and packaging bugs want file listings, not more arithmetic.
Limitations, and who should skip this
This approach assumes you can run a probe on something close to production. A free server is not your production AMI, your distroless image, or your Windows runner. Passing tzprobe.py on one Linux box does not prove a Kubernetes emptyDir or a read-only root filesystem will still contain /usr/share/zoneinfo later. Do not treat any remote sandbox as a replica of prod just because it is not your laptop.
Do not use named zones if all you needed was UTC. Do not vendor the entire IANA database into an app that only subtracts timestamps. Do not take model-generated timezone code as evidence that DST is handled. You still need tests around transitions if civil time actually matters to the product.
Skip this whole ritual if your runtime already guarantees tzdata (many full desktop distros do) and your code only uses timezone.utc. Also skip it if you cannot install packages or inspect /usr/share/zoneinfo, because the probe will only tell you that you are stuck. Once I stopped asking the model to rewrite the parser, the missing directory was obvious. I still want the assistant in the loop for command ideas. I just will not let it delete the evidence again.
Top comments (0)