We reproduced this with CPython 3.12.14 on Windows and the tzdata 2026.3 package. Our fixture checks 17 results across local keys, UTC keys, serialization and arithmetic. It uses synthetic dates, not a production incident. The opening image is a generated illustration. The behaviour is documented and has been reported before; the useful part is checking which representation actually keeps both records.
Two insertions leave one entry
The fixture starts from two unambiguous UTC instants on 25 October 2026 and converts them into Europe/Warsaw. They become 02:30 with offsets +02:00 and +01:00 respectively. Starting in UTC lets the zone rules select the first and second occurrences automatically.
from datetime import datetime, UTC
from zoneinfo import ZoneInfo
zone = ZoneInfo("Europe/Warsaw")
first = datetime(2026, 10, 25, 0, 30, tzinfo=UTC)
second = datetime(2026, 10, 25, 1, 30, tzinfo=UTC)
first = first.astimezone(zone)
second = second.astimezone(zone)
events = {first: "first", second: "second"}
print(first == second, len(events), events[first])
# True 1 second
Both objects share the same tzinfo object. The Python datetime documentation specifies that comparisons in this case ignore the zone and fold, comparing the underlying date and time fields. Those fields are identical here. Our test also confirms equal hashes, so the dictionary treats the second insertion as an update to the existing key.
The fold flag still works for interpreting the offset: the first object has fold=0, the second fold=1. ZoneInfo’s documentation describes how it selects these values during conversion. The flag distinguishes the occurrences without making same-zone equality compare their instants.
This consequence already appears in CPython issue 116035, including an overwritten dictionary value. It is not a new autumn 2026 regression, and simply choosing timezone-aware objects does not change the comparison rule.

Convert both keys before inserting them
For records keyed by an instant, normalize each key to UTC with astimezone. PEP 495 explicitly recommends converting both operands to UTC when a same-zone comparison needs to respect the repeated hour. Apply that normalization consistently, rather than converting only one side of a lookup.
records = [(first, "first"), (second, "second")]
by_instant = {
dt.astimezone(UTC): label
for dt, label in records
}
assert len(by_instant) == 2
for dt in by_instant:
print(dt.isoformat())
# 2026-10-25T00:30:00+00:00
# 2026-10-25T01:30:00+00:00
Our original local subtraction returned zero seconds. Subtracting the two UTC representations returned 3,600 seconds, as did subtracting their timestamps. The timezone-aware objects contained enough information to identify distinct instants; the chosen operation determined whether that information affected the result.
Avoid dt.replace(tzinfo=UTC) as a shortcut. In our test it relabelled both readings as 02:30 UTC, keeping the collision and changing the instants. A correct conversion takes the first reading to 00:30 UTC. Replacing a label and converting a known instant are different operations.
Keep the offset when exporting a recorded time
The same test exposed a second way to lose the distinction. Formatting both readings as 2026-10-25 02:30 produced one string key. No later parser can recover which occurrence each identical string meant without additional information.
Using isoformat() retained the numeric offsets. Parsing those two offset-bearing strings with datetime.fromisoformat() produced two distinct keys in our fixture. Those parsed objects carry fixed offsets, not the original Europe/Warsaw rule set. That is enough to identify these recorded instants; it is not a substitute for a named zone when generating future local appointments.
Our daily-calendar calculation addresses that separate requirement: keeping 09:00 in a city can require a 25-hour elapsed interval. A stored UTC instant alone does not say how tomorrow’s appointment should be generated. Keep the recurrence rule and named zone when the application needs them.
Make the repeated hour a regression case
For an instant-keyed import, retain a fixture with two inputs from the repeated hour and require two output records after normalization. Check their one-hour UTC difference, and test the actual export/import path so an offset is not dropped between stages. A daytime example cannot expose this collision.
A timestamp is also not a universal event ID. Two genuine events can occur at the same instant. If both must survive, use a record identifier or a collection of events per instant. Correct timezone handling does not settle the application’s definition of uniqueness.
Run the checks at the point where records are grouped or deduplicated. A dictionary can already discard a value before a database sees it; our SQLite cursor experiment covers a different database-state problem. Similarly, the ThreadPoolExecutor timeout test concerns waiting and cleanup. Passing either test gives no assurance about datetime key equality.
This fixture covers one repeated hour, one named zone and one executed Python version. It does not test every timezone library, springtime gaps or calendar delivery. In this case the repair is concrete: preserve enough information to identify each instant, convert every instant key consistently, and assert that both records remain.




