Overview
Time zone bugs are unusually nasty because they pass every test you write and fail twice a year in production, usually at 2am local time on a Sunday. Python's standard library got meaningfully better at this in 3.9 with zoneinfo, but the APIs still let you shoot yourself in the foot.
The rule that prevents almost all of it: store UTC, convert at the edges, and never create a naive datetime.
Naive versus aware
A naive datetime has no timezone information attached. It's a wall clock reading with no answer to "which wall."
from datetime import datetime
naive = datetime.now()
print(naive.tzinfo) # None
An aware datetime knows its offset:
from datetime import datetime, timezone
aware = datetime.now(timezone.utc)
print(aware.tzinfo) # UTC
Python will happily let you subtract one from the other, and it will be wrong. It'll also let you compare them, which raises TypeError in Python 3 — a small mercy.
If you remember one thing from this article: use datetime.now(timezone.utc), not datetime.utcnow(). The latter returns a naive datetime and is deprecated for exactly this reason.
zoneinfo in practice
zoneinfo replaced pytz as the recommended approach. It uses the system's IANA time zone database (or the tzdata package on systems that lack one):
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
utc_now = datetime.now(timezone.utc)
# Convert to a local zone for display
tokyo = utc_now.astimezone(ZoneInfo("Asia/Tokyo"))
new_york = utc_now.astimezone(ZoneInfo("America/New_York"))
print(tokyo) # 2026-09-18 23:30:00+09:00
print(new_york) # 2026-09-18 10:30:00-04:00
Note that astimezone is the right method, not replace. Using replace(tzinfo=...) on an aware datetime tells Python "this same wall clock reading is now in a different zone," which is almost never what you mean:
# Wrong: changes the zone without adjusting the time
utc_now.replace(tzinfo=ZoneInfo("Asia/Tokyo"))
# Right: same instant, expressed in another zone
utc_now.astimezone(ZoneInfo("Asia/Tokyo"))
This mistake is responsible for a lot of off-by-nine-hours bugs.
Install tzdata if you're on a slim container
Alpine and python:*-slim images often have no system zone database, so ZoneInfo("America/New_York") raises ZoneInfoNotFoundError at runtime — in production, not in tests, because your dev machine has the database.
pip install tzdata
That package ships the database with your application, which is what you want anyway. You get consistent behavior regardless of the host OS.
DST is where the real bugs live
Most zones have a one-hour gap in spring and a one-hour overlap in autumn. Both produce ambiguous or nonexistent local times.
In the US, 2:00–3:00 AM on the second Sunday in March doesn't exist. If you construct a naive datetime for that range and attach a zone, Python picks something:
from datetime import datetime
from zoneinfo import ZoneInfo
# 2:30 AM on a spring-forward day in New York
dt = datetime(2026, 3, 8, 2, 30, tzinfo=ZoneInfo("America/New_York"))
print(dt) # 2026-03-08 02:30:00-05:00
That timestamp never existed, but Python produced one anyway, silently choosing the pre-transition offset. Same problem in reverse during the autumn overlap: 1:30 AM happens twice, and you get the first occurrence.
This matters most for scheduling. A daily job scheduled at 2:30 AM local time will skip a day or run twice depending on the transition. If you're building a scheduler, schedule in UTC and convert for display, or use a library that understands the calendar semantics — APScheduler with a timezone-aware trigger, or croniter with awareness of the zone.
Working with an API
Most APIs that do this correctly accept and return ISO 8601 with an offset. Python 3.11+ parses that out of the box:
from datetime import datetime
dt = datetime.fromisoformat("2026-09-18T14:30:00+02:00")
print(dt.tzinfo) # UTC+02:00
On older versions, fromisoformat chokes on the Z suffix that many APIs use for UTC. Work around it:
def parse_iso(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
Storing and comparing
| Context | What to do |
|---|---|
| Database column | Use timestamptz in Postgres, not timestamp. It stores UTC and handles conversion. |
| Log timestamps | UTC, always, with the offset in the string |
| API responses | ISO 8601 with offset, or epoch seconds |
| User-facing display | Convert at the very last moment, in the presentation layer |
| Business logic | UTC, no exceptions |
The Postgres point deserves emphasis because it's a one-way migration later. timestamp without time zone stores a wall clock reading with no offset. When you later need to know what instant it referred to, you can't reconstruct it. Use timestamptz from the start.
Users' time zones
You need two things: the user's zone, and whether they want a fixed zone or their current one.
# From a stored preference
user_tz = ZoneInfo(user.timezone) # "Europe/Berlin"
# From a browser, sent to your API
user_tz = ZoneInfo(request.headers["X-Timezone"])
# Fixed offset from a client that only sent a UTC offset
from datetime import timezone, timedelta
offset = timezone(timedelta(minutes=request.json["utc_offset_minutes"]))
Store the IANA name (Europe/Berlin), not the offset. Offsets change twice a year in most places; the zone name doesn't. A user in Berlin who has +01:00 saved will see all their times an hour off for half the year.
The checklist
datetime.now(timezone.utc)— neverutcnow(), never naivenow()astimezone()to convert, neverreplace(tzinfo=...)zoneinfo, notpytzpip install tzdatain containerstimestamptzin Postgres- Store IANA zone names, not offsets
- Convert in the presentation layer only
- Test with a spring-forward and a fall-back date in your test suite — add them now, they're two lines
