Search code examples
pythondatetimetimezonetimedeltadst

Python datetime: unexpected dependency between timedelta and daylight saving time


I want to know if there's a daylight saving time change within the next hours. Thereby I recognized an unexpected dependency between datetime.timedelta, astimezone and daylight saving time. During the daylight saving time change the timedelta calculation seems no to work. Here's a simple example:

import datetime
import pytz

format = '%Y-%m-%d %H:%M:%S %Z%z'
local_tz = pytz.timezone('Europe/Berlin')
time_diff = datetime.timedelta(hours = 1)

print((datetime.datetime(2023, 2, 26, 1, 0, 0) + time_diff).astimezone(local_tz).strftime(format))
# => 2023-02-26 02:00:00 CET+0100

print((datetime.datetime(2023, 3, 26, 1, 0, 0) + time_diff).astimezone(local_tz).strftime(format))
# => 2023-03-26 01:00:00 CET+0100

I would expect that the last print would result "2023-03-26 02:00:00 CET+0100" or "2023-03-26 03:00:00 CEST+0200". Does anyone can explain this behaviour?

After different tries I found a solution by adding the time delta after adding the timezone to the timestamp.

print((datetime.datetime(2023, 3, 26, 1, 0, 0).astimezone(local_tz) + time_diff).strftime(format))

But I still don't understand the error in my first used code.

My versions:
- Python 3.10.2
- pytz 2022.7


Solution

  • See also this answer by Paul Ganssle - with native Python's timedelta combined with time zones, non-existing datetimes (like 2023-02-26 02:00:00 CET+0100) have to be expected.

    Here's a slightly extended comparison for reference, + comments in the code. pytz is deprecated since the release of Python 3.9's zoneinfo.

    from datetime import datetime, timedelta, timezone
    from zoneinfo import ZoneInfo
    import pandas as pd
    import pytz
    
    def absolute_add(dt: datetime, td: timedelta) -> datetime:
        utc_in = dt.astimezone(timezone.utc)  # Convert input to UTC
        utc_out = utc_in + td  # Do addition in UTC
        civil_out = utc_out.astimezone(dt.tzinfo)  # Back to original tzinfo
        return civil_out
    
    # -----
    # in vanilla Python, we can create non-existent datetime...
    
    # I) tz already set
    t = datetime(2023, 3, 26, 1, tzinfo=ZoneInfo("Europe/Berlin"))
    print(t + timedelta(hours=1))
    # 2023-03-26 02:00:00+01:00
    # this datetime should not exist in that time zone since there is a DST transtion,
    # 1 am UTC+1 plus one hour gives 3 am UTC+2
    
    # II) tz set after timedelta addition
    print(datetime(2023, 3, 26, 1) + timedelta(hours=1))
    # 2023-03-26 02:00:00
    # this is ok since no tz specified
    print((datetime(2023, 3, 26, 1) + timedelta(hours=1)).replace(tzinfo=ZoneInfo("Europe/Berlin")))
    # 2023-03-26 02:00:00+01:00
    # again, we have a non-existent datetime
    print((datetime(2023, 3, 26, 1) + timedelta(hours=1)).astimezone(ZoneInfo("Europe/Berlin")))
    # 2023-03-26 01:00:00+01:00
    # also a bit confusing; 2 am would be non-existing, so the hour is "corrected"
    # backwards before setting the time zone
    
    # -----
    # adding the timedelta in UTC as "absolute duration" works as expected:
    print(absolute_add(t, timedelta(hours=1)))
    # 2023-03-26 03:00:00+02:00
    
    # with pytz timezones, you can normalize to correct the non-existent datetime:
    tz = pytz.timezone("Europe/Berlin")
    t = tz.localize(datetime(2023, 3, 26, 1))
    print(tz.normalize(t + timedelta(hours=1)))
    # 2023-03-26 03:00:00+02:00
    
    # this is correctly implemented in pandas for instance:
    t = pd.Timestamp(2023, 3, 26, 1).tz_localize("Europe/Berlin")
    print(t + pd.Timedelta(hours=1))
    # 2023-03-26 03:00:00+02:00