Equality, hashing and arithmetic semantics

datetime · Expert · 12 min

Sets, dict keys and sorting of aware datetimes behave surprisingly around DST unless you know the rules.

datetime defines two kinds of operations between aware values:

  • Intra-zone: both operands share the same tzinfo object. Comparison and subtraction then use the naive wall-clock fields and ignore utcoffset() and fold. So the two occurrences of 1:30 on a fall-back night compare equal, and subtracting across a DST change gives wall-clock differences.
  • Inter-zone: different tzinfo objects. Both sides are converted to UTC first, so the result reflects real elapsed time.

Same zone name isn't enough; it has to be the same object. ZoneInfo caches instances, so ZoneInfo("Europe/Berlin") usually yields the same object, but a ZoneInfo.no_cache(...) instance or a different tzinfo implementation flips the behaviour.

PEP 495 adds an exception for inter-zone comparison: if either operand is in a gap or fold where fold would change its offset, == returns False (a "different instant" guarantee), which makes equality non-transitive in rare cases.

Hashing uses the UTC value for aware datetimes, with fold ignored, so datetimes that are equal hash equally. Naive and aware values never compare equal, so a dict can hold both datetime(2025,1,1) and its aware twin as separate keys.

The practical rule: convert to UTC before comparing, sorting or deduplicating instants.

Example

from datetime import UTC, datetime
from zoneinfo import ZoneInfo

ny = ZoneInfo("America/New_York")
first = datetime(2025, 11, 2, 1, 30, tzinfo=ny)
second = first.replace(fold=1)                      # one hour later in reality
print(first == second)                              # True: intra-zone ignores fold
print(second - first)                               # 0:00:00
u1, u2 = first.astimezone(UTC), second.astimezone(UTC)
print(u1 == u2, u2 - u1)                            # False 1:00:00

other = ZoneInfo.no_cache("America/New_York")       # same rules, new object
print(first == second.replace(tzinfo=other))        # False: inter-zone, PEP 495
print(len({first, second}))                         # 1

naive = datetime(2025, 1, 1)
print(len({naive, naive.replace(tzinfo=UTC)}))      # 2

Remember

  • Same tzinfo object: wall-clock comparison and subtraction (fold ignored)
  • Different tzinfo objects: both sides converted to UTC first
  • Convert to UTC before comparing, sorting or deduplicating instants

Watch out for

  • Deduplicating events by aware local datetimes, which merges the two different 1:30s on a fall-back night.

Check yourself

  1. a and b are aware datetimes with the same ZoneInfo object, one hour apart in real time across a fall-back transition, both reading 01:30. What is a == b?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/datetime.html#aware-and-naive-objects