Have you ever trusted a timezone conversion that only existed because your laptop still had tzdata installed? I spent forty-eight hours on a scheduler that looked punctual locally and then died inside a slim image. The job promised 09:00 in America/New_York, and my laptop logs agreed with that promise every morning. The container did not agree at all, and ZoneInfoNotFoundError showed up only after I stopped blaming cron.
Hour 0 to 8: I treated the laptop as ground truth
Why would I question a conversion that printed the offset I expected in my terminal? I ran a tiny snippet, stared at a -05:00 or -04:00 suffix, and called the clock honest. I never printed TZPATH, and I never asked whether the IANA files were actually on disk. That is a bad habit, and it cost me the first evening of quiet, circular debugging.
from datetime import datetime
from zoneinfo import ZoneInfo
# Looks healthy on a workstation that already has IANA data.
now = datetime.now(ZoneInfo("America/New_York"))
print(now.isoformat())
Would you have opened the image next, or would you have kept reading scheduler docs like I did? I kept reading docs. The snippet above is not a test of production. It is a test of whatever timezone files your laptop happened to smuggle in.
What I tried before I understood the failure
- I printed naive
datetime.now()and compared it with the wall clock on my desk. - I passed
"America/New_York"into the job wrapper and trusted the string to resolve later. - I watched CI on a full Ubuntu runner, and the pytest file stayed green the entire time.
- I blamed the orchestrator for eating the timezone name before the process started.
- I caught
ZoneInfoNotFoundErrorin one helper and fell back to naive local time.
That fifth item was the worst idea in the whole notebook. It kept the process alive, and it taught me the wrong lesson for another day.
Hour 8 to 24: I blamed the scheduler
Was the library ignoring the timezone argument, or was I passing a name it never resolved at runtime? I added logging around job start, and the worker came up locally without a single traceback. CI stayed green because the runner still ships tzdata, which is a cruel kind of success. The deploy image used a slim base, and that difference never appeared in the pytest output I trusted.
I even compared cron expressions character by character, like a person who has run out of better theories. The expression was fine. The zone name was fine. The machine that lacked IANA data was not fine, and I had not asked that machine anything yet.
A fallback that looks resilient and is not
Have you ever swallowed ZoneInfoNotFoundError so the service would keep starting? I did that in one helper, and the job began "working" with the wrong local offset. The timestamps looked plausible, so nothing paged, and I kept chasing an off-by-one in the schedule. Failing loud would have saved the second day, and I will not wrap that import again.
from datetime import datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def now_in_city(name: str) -> datetime:
try:
return datetime.now(ZoneInfo(name))
except ZoneInfoNotFoundError:
# Looks resilient. It hides a missing tz database.
return datetime.now() # naive, local, and lying
If the container local time is UTC, this helper silently shifts civil time. If the container local time is not UTC, the helper still lies, only in a different direction. Either way you store a wall-clock value that nobody can reconstruct later.
Hour 24 to 40: CI green, slim image red
Why did the tests keep passing while the deployed worker could not even construct the zone? Because I was not running pytest in the image I shipped. The runner had /usr/share/zoneinfo. The slim tag often does not. My laptop had a transitive tzdata wheel from some other dependency I never pinned on purpose.
I reproduced the deploy image locally instead of arguing with scheduler configuration for another wasted hour. The traceback named ZoneInfoNotFoundError, and after that the rest of the messy story snapped into place. No orchestrator bug. No cron parser bug. Just a missing IANA database and a test matrix that never used the production base image.
# Run this against the tag you actually ship, not the tag you develop on.
docker run --rm python:3.12-slim python -c "from zoneinfo import ZoneInfo; ZoneInfo('America/New_York')"
On the slim tag I pulled for this note, that one-liner raised. On my laptop, the same one-liner printed nothing and exited zero. That gap is the entire incident.
The probe I should have run in hour one
I wanted one script that prints interpreter, TZPATH, whether the tzdata wheel is importable, and whether each required zone resolves. Label this as a probe you can run; it is not a benchmark and it is not a production service. Save it as probe_tzdata.py and execute it on every image that will construct civil time.
#!/usr/bin/env python3
"""Probe IANA zone resolution for this interpreter. Run on every image you ship."""
from __future__ import annotations
import os
import sys
import traceback
from datetime import datetime, timezone
from zoneinfo import TZPATH, ZoneInfo, ZoneInfoNotFoundError
ZONES = ("UTC", "America/New_York", "Europe/London", "Asia/Shanghai")
def main() -> int:
print(f"python={sys.version.split()[0]}")
print(f"platform={sys.platform}")
print(f"TZPATH={TZPATH!r}")
print(f"TZ={os.environ.get('TZ')!r}")
try:
import tzdata # type: ignore
print(f"tzdata_pkg={getattr(tzdata, '__file__', 'present')}")
except ImportError:
print("tzdata_pkg=missing")
failures = 0
for name in ZONES:
try:
zi = ZoneInfo(name)
now = datetime.now(zi)
print(f"ok zone={name} key={zi.key} now={now.isoformat()}")
except ZoneInfoNotFoundError as exc:
failures += 1
print(f"FAIL zone={name} err={exc}")
except Exception:
failures += 1
traceback.print_exc()
print(f"stdlib_utc={datetime.now(timezone.utc).isoformat()}")
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(main())
Then pin the same assertion into pytest, and run that file inside the deploy image. A green run on ubuntu-latest does not count if production is python:*-slim.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import pytest
REQUIRED = ("America/New_York", "Europe/London", "Asia/Shanghai")
@pytest.mark.parametrize("name", REQUIRED)
def test_required_zones_resolve(name: str) -> None:
zi = ZoneInfo(name) # must not raise ZoneInfoNotFoundError
aware = datetime.now(zi)
assert aware.tzinfo is not None
assert aware.utcoffset() is not None
def test_utc_does_not_need_iana_files() -> None:
aware = datetime.now(timezone.utc)
assert aware.tzinfo is timezone.utc
def test_missing_zone_fails_loud() -> None:
with pytest.raises(ZoneInfoNotFoundError):
ZoneInfo("Not/A_Real_Zone")
Commands I would run on the next incident
python probe_tzdata.py
echo $?
python -c "import tzdata, pathlib; print(tzdata.__file__)"
python -c "from zoneinfo import TZPATH; print(TZPATH)"
docker build -t tz-probe -f Dockerfile.probe .
docker run --rm tz-probe
A minimal Dockerfile for the failing case looks like this. Confirm the tag on your machine; do not memorize whether a future slim image started bundling tzdata.
FROM python:3.12-slim
WORKDIR /app
COPY probe_tzdata.py .
CMD ["python", "probe_tzdata.py"]
Two honest fixes, pick one and pin it:
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends tzdata \
&& rm -rf /var/lib/apt/lists/*
pip install tzdata
UTC itself should not depend on those files. Use datetime.now(timezone.utc) for storage and comparison. Keep ZoneInfo for civil-time display and for schedules that must follow a named city's rules, including DST.
Where a clean box actually helped
I needed a machine that was not my laptop, because my laptop kept smuggling tzdata into every result. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free server option to run the probe on a stock interpreter instead of my decorated workstation. I also used the free model access to review the probe and to list assertions I had forgotten to print, like TZPATH and a loud failure for a fake zone. The model did not know my image layout, and I still had to run the commands myself. If you want a second box that is not your laptop, that free server option is a convenient place to park the same script.
Decision table for the next timezone scare
| Symptom | Likely cause | First check | Do not do |
|---|---|---|---|
ZoneInfoNotFoundError in the container only |
distro tzdata or the tzdata wheel is missing |
TZPATH, import tzdata, probe script |
catch the error and call naive now()
|
| CI green, production red | tests are not using the deploy image | run pytest in the shipped tag | trust a full ubuntu-latest runner |
| Offset wrong near March or November | hardcoded UTC-5 / UTC-4
|
datetime.now(ZoneInfo(name)).utcoffset() |
store naive local datetimes |
| Windows laptop fine, Linux slim fails | a transitive tzdata wheel on the laptop |
pip show tzdata in both places |
assume IANA files exist on every base image |
ZoneInfo("UTC") fails, UTC timestamps still needed |
IANA files missing, but stdlib UTC is enough | datetime.now(timezone.utc) |
block deploys on UTC display code |
Read the table left to right before you open scheduler source again. Most of my wasted hours were spent in the rightmost column.
What I would repeat
- Run
probe_tzdata.pyon the exact deploy image before reading another page of scheduler documentation. - Pin
tzdatain production requirements, or install the distro package in the Dockerfile, and commit that choice. - Keep storage in
timezone.utcso UTC never depends on IANA files being present. - Fail loud on missing zones. Do not fall back to naive local time to keep the process up.
- Run the same pytest file inside the image you ship, not only on the CI runner's full OS.
Would I still inspect cron expressions? Yes, after the probe is green. The probe is cheaper than another evening of comparing asterisks. I would also print TZPATH in the boot log of any worker that schedules civil time, because that one line would have ended this note on hour one.
Limitations, and who should not copy this
This probe does not prove your DST transitions are correct for every historical civil date you care about. It also does not replace a pinned tzdata version when you need reproducible civil-time arithmetic across years. A free remote box is not your production network, so DNS, NTP, and the orchestrator's own timezone still need separate checks.
Do not send production logs that contain customer timestamps or other personal data to a hosted model. Do not treat model-suggested offsets as legal or astronomical truth. If you are air-gapped, or you cannot run third-party hosted tools, skip the remote box and keep the probe, the image, and the pytest file. If your runtime is already a full OS image with tzdata managed by the distro, this note is still useful as a regression test, not as a reason to add another package.
I would repeat the probe. I would not repeat the fallback. And I would not ask my laptop to represent production ever again, at least not for named timezones.
Top comments (0)