Skip to content

← Stdlib Mastery step 53 of 55

Hard Primitives

datetime: reject naive input, and find the next local wall clock

Two boundary functions that together cover the whole of the naive/aware and DST minefield.

def parse_timestamp(text: str) -> datetime: ...
def exists_in_zone(candidate: datetime) -> bool: ...
def next_occurrence(after: datetime, local_time: time, tz: str) -> datetime: ...
def solve(stamps: list[str], after: str, local_time: str, tz: str) -> dict[str, object]: ...
  • parse_timestamp parses an ISO-8601 string and returns an aware UTC datetime. Naive input — no offset — raises ValueError. Z and explicit offsets both work (datetime.fromisoformat accepts Z from 3.11).
  • next_occurrence returns the next UTC instant strictly after after at which the local wall clock in tz reads local_time. Skip days on which that wall clock does not exist. When it occurs twice, take the first.
  • solve maps each stamp to parse_timestamp(...).isoformat() or "ValueError", parses local_time as "HH:MM", and returns {"parsed": [...], "next": next_occurrence(...).isoformat()}.

Silent corruption source one: naive versus aware. datetime.utcnow() returns a naive datetime whose fields happen to hold UTC. Comparing it to an aware datetime raises — the good outcome, because it is loud. Calling .timestamp() on it interprets the wall clock as local time: measured under TZ=America/Los_Angeles, 28,800 seconds of skew, silently, forever, in your database. And replace(tzinfo=...) attaches a zone without converting (moving the instant), while astimezone() converts it (keeping the instant).

Silent corruption source two: DST. Arithmetic on a zoned datetime is wall-clock, not absolute: datetime(2026, 3, 7, 12, 0, tzinfo=ZoneInfo("America/New_York")) + timedelta(days=1) gives the same wall clock, but only 82,800 real seconds elapse. On 8 March 2026, 02:30 in New York does not exist — and Python silently accepts the construction, giving you an object that reads 03:30 when converted back. On 1 November 2026, 01:30 occurs twice; two datetimes differing only in fold compare equal intra-zone and convert to UTC instants an hour apart.

exists_in_zone is the detection idiom: normalise the candidate through UTC and back, and compare the wall clocks. If normalising changed the reading, the reading never happened.

The type system cannot help here, at all. Aware and naive are both datetime; there is no annotation that distinguishes them. This is the clearest case in the course for an invariant the checker cannot express, which is why it has to be enforced by a boundary function — and why the emptiness of -> datetime as a contract is worth feeling.

Note the documented aware-check is dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None: a tzinfo may return None from utcoffset, making the datetime aware by attribute and naive in behaviour.

zoneinfo (3.9) reads the system tz database and its objects go straight into tzinfo=. It replaces pytz, whose localize()/normalize() dance existed because attaching a bare pytz zone with replace gives an obsolete LMT offset — the famous “seven minutes off” bug.