Technology · PRACTICAL GUIDE
Python ZoneInfo: check missing times without rejecting a valid DST fold
A direct equality check against UTC can reject both occurrences of a real repeated hour. Validate the wall-clock round trip first, then resolve ambiguity and choose an instant key.

A timezone-aware datetime is not proof that the local clock reading ever occurred. Python can construct a ZoneInfo datetime inside a spring-forward gap. But the opposite mistake is possible too: a shortcut that compares a datetime directly with its UTC conversion can label a legitimate repeated-hour value as nonexistent.
Keep three decisions separate: does this wall-clock reading exist, which occurrence did the user mean, and how should the chosen instant be stored or compared? The following check preserves both valid fold occurrences and rejects a missing local time without silently moving it.
1. Recognize the false-negative check
The pytz-deprecation-shim migration guide offers a small standard-library existence helper based on dt.astimezone(timezone.utc) == dt. Its nearby dateutil-based recipe is a different approach. The equality shortcut fails for a valid fold-aware time when changing fold changes its UTC offset: PEP 495 deliberately makes that inter-zone equality comparison false. A False result therefore does not prove a gap.
In a bounded CPython 3.12.14 fixture, Europe/London at 2024-10-27 01:30 with fold=0 converts to 00:30 UTC, while fold=1 converts to 01:30 UTC. Both are real instants. Direct equality with each UTC conversion returned False for both. Treat this as a regression case for the helper, not evidence that ZoneInfo cannot represent the repeated hour.
2. Compare the round-tripped wall clock
For a datetime dt whose tzinfo is a ZoneInfo instance, first require an aware value: dt.tzinfo must be present and dt.utcoffset() must not be None. Compute back = dt.astimezone(timezone.utc).astimezone(dt.tzinfo). Then set exists = back.replace(tzinfo=None) == dt.replace(tzinfo=None). The comparison deliberately checks the original local date and clock fields after converting there and back; it does not compare the original value directly against a different zone.
Reject the input if exists is False, or apply a separately documented product policy. Do not automatically replace dt with back and call that validation: a missing input would have been changed. If dateutil is already part of the application, its documented datetime_exists(dt) and datetime_ambiguous(dt) helpers avoid maintaining a custom implementation. Run the same fixture against whichever implementation you deploy.
3. Resolve ambiguity only after existence
After exists is True, a ZoneInfo value is ambiguous when dt.replace(fold=1-dt.fold).utcoffset() differs from dt.utcoffset(). Ask for, retain or explicitly choose the earlier occurrence (fold=0) or later occurrence (fold=1). Setting fold to a value is not itself a validation step. This existence test answers whether the wall-clock reading occurs; it is not a strict validator of every supplied fold label outside a repeated interval.
If an upstream record already identifies an instant through UTC or an offset, preserve that information instead of guessing again from an offset-free local string. ZoneInfo sets fold appropriately when converting an existing instant. For user-entered local appointments, preserve the zone key and the chosen ambiguity policy as well as the resulting instant. Do not hard-code a one-hour adjustment: transition sizes and rules are properties of the selected zone data.
4. Keep a four-case regression matrix
Using ZoneInfo('Europe/London'), the fixture checked an ordinary 2024-10-26 01:30 value, both fold values at 2024-10-27 01:30, and the missing 2024-03-31 01:30 value. The round-trip existence results were True, True, True and False. The ambiguity results, evaluated only after existence, were False, True, True and False. The gap returned from UTC as 02:30 local, exposing that the original wall clock did not survive.
These are historical fixtures, not predictions of every future transition. Record the Python version, zone key and timezone-data source used by the application, and add cases for its actual service regions. ZoneInfo reads system timezone data or the first-party tzdata package when needed. A missing data package or invalid zone key is a separate initialization problem; it is not a DST gap and should not be converted into a user-input rejection.
5. Use an instant key after validation
Once the input exists and its occurrence is resolved, use dt.astimezone(timezone.utc) when the business identity is an instant. In the fixture, putting the two London fold values into a set with their shared ZoneInfo object produced one local key; converting them to UTC first produced two. Subtraction of the local pair gave zero seconds, while subtraction of the UTC pair gave 3,600 seconds. Those are different comparison semantics, not interchangeable storage choices.
This final conversion serves event ordering, deduplication by instant and elapsed-duration calculations. A recurring local-time schedule may deliberately need wall-clock semantics, so keep its original local rule. Changing keys now cannot reconstruct an event already overwritten by a local-key collision; recover it from retained source records if available. Accept the repair only when ordinary times, both repeated occurrences and the gap all take their intended paths.
