Ambiguity in timezones

Note

The API for handling ambiguity is largely inspired by that of Temporal, the redesigned date and time API for JavaScript.

In timezones, local clocks are often moved backwards and forwards due to Daylight Saving Time (DST) or political decisions. This makes it complicated to map a local time to a point on the timeline. Two common situations arise:

  • When the clock moves backwards, there is a period of time that repeats. For example, Sunday October 29th 2023 2:30am occurred twice in Paris. When you specify this time, you need to specify whether you want the earlier or later occurrence.

  • When the clock moves forwards, a period of time is skipped. For example, Sunday March 26th 2023 2:30am didn’t happen in Paris. When you specify this time, you need to specify how you want to handle this non-existent time. Common approaches are to extrapolate the time forward or backwards to 1:30am or 3:30am.

    Why extrapolate skipped time?

    You may wonder why skipped time is “extrapolated” like this, and not truncated. Why turn 2:30am into 3:30am and not cut it off at 1:59am when the gap occurs?

    The reason for the “extrapolation” approach is:

    • It fits the most likely reason the time is skipped: we forgot to adjust the clock, or adjusted it too early

    • This is how other datetime libraries do it (e.g. JavaScript (Temporal), C# (Nodatime), Java, Python itself)

    • It corresponds with the iCalendar (RFC5545) standard of handling gaps

    The figure in the Python docs here also shows how this “extrapolation” makes sense graphically.

whenever allows you to customize how to handle these situations using the disambiguate argument:

disambiguate

Behavior in case of ambiguity

"raise"

Raise RepeatedTime or SkippedTime exception.

"earlier"

Choose the earlier of the two options

"later"

Choose the later of the two options

"compatible" (default)

Choose “earlier” for backward transitions and “later” for forward transitions. This matches the behavior of other established libraries, and the industry standard RFC 5545. It corresponds to setting fold=0 in the standard library.

>>> paris = "Europe/Paris"

>>> # Not ambiguous: everything is fine
>>> ZonedDateTime(2023, 1, 1, tz=paris)
ZonedDateTime("2023-01-01 00:00:00+01:00[Europe/Paris]")

>>> # --- Fold: 2:30am occurs TWICE (clocks fall back) ---

>>> # Reject ambiguous times outright
>>> ZonedDateTime(2023, 10, 29, 2, 30, tz=paris, disambiguate="raise")
Traceback (most recent call last):
    ...
whenever.RepeatedTime: 2023-10-29 02:30:00 is repeated in timezone Europe/Paris

>>> # Explicitly choose the earlier occurrence (summer time, +02:00)
>>> ZonedDateTime(2023, 10, 29, 2, 30, tz=paris, disambiguate="earlier")
ZonedDateTime("2023-10-29 02:30:00+02:00[Europe/Paris]")

>>> # Explicitly choose the later occurrence (winter time, +01:00)
>>> ZonedDateTime(2023, 10, 29, 2, 30, tz=paris, disambiguate="later")
ZonedDateTime("2023-10-29 02:30:00+01:00[Europe/Paris]")

>>> # Default ("compatible") picks "earlier" for folds — matching RFC 5545
>>> ZonedDateTime(2023, 10, 29, 2, 30, tz=paris)
ZonedDateTime("2023-10-29 02:30:00+02:00[Europe/Paris]")

>>> # The two occurrences are exactly 1 hour apart in real time:
>>> earlier = ZonedDateTime(2023, 10, 29, 2, 30, tz=paris, disambiguate="earlier")
>>> later   = ZonedDateTime(2023, 10, 29, 2, 30, tz=paris, disambiguate="later")
>>> later - earlier
TimeDelta("PT1h")

>>> # --- Gap: 2:30am DOESN'T EXIST (clocks spring forward) ---

>>> ZonedDateTime(2023, 3, 26, 2, 30, tz=paris, disambiguate="raise")
Traceback (most recent call last):
    ...
whenever.SkippedTime: 2023-03-26 02:30:00 is skipped in timezone Europe/Paris

>>> # "earlier" extrapolates backward → 1:30 AM (before the gap)
>>> ZonedDateTime(2023, 3, 26, 2, 30, tz=paris, disambiguate="earlier")
ZonedDateTime("2023-03-26 01:30:00+01:00[Europe/Paris]")

>>> # "later" extrapolates forward → 3:30 AM (after the gap)
>>> ZonedDateTime(2023, 3, 26, 2, 30, tz=paris, disambiguate="later")
ZonedDateTime("2023-03-26 03:30:00+02:00[Europe/Paris]")

>>> # Default ("compatible") picks "later" for gaps — matching RFC 5545
>>> ZonedDateTime(2023, 3, 26, 2, 30, tz=paris)
ZonedDateTime("2023-03-26 03:30:00+02:00[Europe/Paris]")