Two independent ways to corrupt timestamps, both of which produce plausible values rather than exceptions.
Naive versus aware
A datetime with tzinfo is None is naive: it carries no offset, so it
denotes a wall-clock reading with no fixed instant behind it.
datetime.utcnow() returns a naive datetime whose fields happen to hold
UTC. It is deprecated for exactly this reason. What goes wrong:
-
Comparing it to an aware datetime raises
TypeError, which is the good outcome, because it is loud. -
Calling
.timestamp()on it interprets the wall clock as local time. Measured underTZ=America/Los_Angeles, that is 28,800 seconds of skew — a timestamp eight hours wrong, silently, forever, in your database.
Use datetime.now(timezone.utc).
The other half of the pair:
dt.replace(tzinfo=ZoneInfo("America/New_York")) # ATTACHES, does not convert
dt.astimezone(ZoneInfo("America/New_York")) # CONVERTS the instant
replace reinterprets the same wall-clock numbers as being in a new zone —
moving the instant. astimezone keeps the instant and changes the numbers.
Using replace where you meant astimezone shifts every timestamp by the
offset, which for a Europe/US mix is a few hours: enough to be wrong, small
enough to look like a rounding problem in a chart.
💡parse_timestamp should reject naive input. Why is
click to reveal
if dt.tzinfo is None not quite enough?
Because a tzinfo object is allowed to return None from utcoffset(),
which makes the datetime aware by attribute and naive in behaviour. It is rare
in practice, and the standard idiom accounts for it:
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
raise ValueError(...)
This is the check the Python documentation itself specifies for “is this datetime aware?”, and worth using verbatim rather than reinventing.
The deeper point is that this check is a runtime one and has to be. The
type system does not distinguish aware from naive — both are datetime — so
--strict cannot help at all with the single most common datetime bug in
existence. If the invariant matters (and it does), it has to be enforced at a
boundary function and carried by convention, a NewType, or a wrapper class.
DST: arithmetic is wall-clock, not absolute
start = datetime(2026, 3, 7, 12, 0, tzinfo=ZoneInfo("America/New_York"))
later = start + timedelta(days=1)
later reads 12:00 on 8 March — the same wall clock — but only 82,800
seconds of real time elapsed, because 8 March 2026 is a spring-forward day
and that day is 23 hours long.
This is a deliberate design choice, not a bug: “same time tomorrow” is usually
what a human means. But it means + timedelta on a zoned datetime does not
add absolute time. If you need real elapsed duration, do the arithmetic in
UTC:
later = (start.astimezone(timezone.utc) + timedelta(days=1)).astimezone(start.tzinfo)
The two DST edge cases
Ambiguous times (fall back). On 1 November 2026, 01:30 in New York occurs
twice. The fold attribute disambiguates: fold=0 is the first occurrence
(EDT), fold=1 the second (EST). Two datetimes differing only in fold
compare equal within the same zone, but convert to different UTC
instants — an hour apart. Equality and identity of instant come apart, which
is as surprising as it sounds.
Nonexistent times (spring forward). On 8 March 2026, 02:30 in New York
does not exist; the clock jumps 02:00 to 03:00. Python silently accepts
the construction. datetime(2026, 3, 8, 2, 30, tzinfo=ZoneInfo("America/New_York"))
is a perfectly happy object that denotes an instant which, converted back to
local time, reads 03:30.
That round-trip is also the detection idiom:
def exists_in_zone(candidate: datetime) -> bool:
normalised = candidate.astimezone(timezone.utc).astimezone(candidate.tzinfo)
return normalised.replace(tzinfo=None) == candidate.replace(tzinfo=None)
If normalising through UTC changes the wall clock, the wall clock never happened.
zoneinfo replaces pytz
zoneinfo (3.9) reads the system tz database and its objects are usable
directly in tzinfo=. pytz required the notorious localize() /
normalize() dance, because a bare pytz timezone attached with replace
gives you an obsolete LMT offset — the famous “seven minutes off” bug. If you
see pytz.timezone(...) in a modern codebase, it is either legacy or a bug
waiting to happen. On Windows, or any system without a tz database, install
the tzdata package.
💡"The next time it is 09:00 in Tokyo" — why can that not be click to reveal
computed as now + timedelta(hours=...)?
Because there is no fixed offset that answers it. The number of hours until
the next 09:00 local depends on the current local time, on whether a DST
transition falls in between (which changes the day length), and on whether
09:00 exists at all on the next candidate date.
The correct algorithm is: convert the reference instant into the target zone, take its date, construct the candidate wall clock on that date with the zone attached, discard it if it does not exist, convert it to UTC, and accept it if it is strictly after the reference. Otherwise advance one calendar day and repeat.
Advancing by calendar day rather than by 24 hours is the load-bearing
detail: you are enumerating candidate wall clocks, not adding durations, and
wall clocks are what the schedule is expressed in. Both DST edge cases fall
out — a nonexistent time is skipped by the existence check, and an ambiguous
time resolves to its first occurrence because fold defaults to 0.