Skip to content
← All articles

datetime: two silent data-corruption sources in one lesson

utcnow() returns a NAIVE datetime that represents UTC, and .timestamp() on it interprets it as local time — measured 28,800 s of skew. Arithmetic on a zoned datetime is wall-clock, not absolute.

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 under TZ=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.