pythondatetimedstpytzzoneinfo

Python pytz, zoneinfo and daylight savings time


I am currently attempting to migrate a code base from using pytz to using Python's zoneinfo library. I've run into an issue with how zoneinfo handles daylight saving time transitions when compared to how pytz handles them.

Suppose I have a naive datetime object:

>>> import datetime
>>> start = datetime.datetime(2021, 10, 30, 23, 0)

The approach used for making this object timezone-aware using pytz was to use localize, as follows:

>>> local_timezone = pytz.timezone('Europe/Copenhagen')
>>> start_cet = local_timezone.localize(start)
>>> start_cet
datetime.datetime(2021, 10, 30, 23, 0, tzinfo=<DstTzInfo 'Europe/Copenhagen' CEST+2:00:00 DST>)

Notice that start_cet is very close to the end of CEST 2021. If I add, say, 5 hours onto this time, I get the following:

>>> end = start_cet + datetime.timedelta(hours=5)
>>> end
datetime.datetime(2021, 10, 31, 4, 0, tzinfo=<DstTzInfo 'Europe/Copenhagen' CEST+2:00:00 DST>)

Now, in reality, adding 5 hours on to start_cet should in fact land us on 3:00 on 2021/10/31, since leaving DST takes us back one hour, moving from CEST to CET. We can get the correct value by applying astimezone with our pytz timezone:

>>> end.astimezone(local_timezone)
datetime.datetime(2021, 10, 31, 3, 0, tzinfo=<DstTzInfo 'Europe/Copenhagen' CET+1:00:00 STD>)

Question: What is the equivalent method of finding the actual time when moving through DST when using zoneinfo?

Here's what I've tried. zoneinfo has no localize method, instead we are recommended to simply use replace:

>>> local_timezone = zoneinfo.ZoneInfo('Europe/Copenhagen')
>>> start_cet = start.replace(tzinfo=local_timezone)
>>> start_cet
datetime.datetime(2021, 10, 30, 23, 0, tzinfo=backports.zoneinfo.ZoneInfo(key='Europe/Copenhagen'))
>>> start_cet.utcoffset()
datetime.timedelta(seconds=7200)

zoneinfo has the benefit over pytz that adding a timedelta will successfully transition us into the correct timezone (as can be seen by the utcoffset result:

>>> end = start_cet + datetime.timedelta(hours=5)
>>> end
datetime.datetime(2021, 10, 31, 4, 0, tzinfo=backports.zoneinfo.ZoneInfo(key='Europe/Copenhagen'))
>>> end.utcoffset()
datetime.timedelta(seconds=3600)

Despite this, with zoneinfo timezones there seems to be no way to get what the actual time would have been when adding 5 hours onto start_cet. As shown above, with pytz I can use astimezone, but with zoneinfo, this does not change anything:

>>> end.astimezone(tz=local_timezone)
datetime.datetime(2021, 10, 31, 4, 0, tzinfo=backports.zoneinfo.ZoneInfo(key='Europe/Copenhagen'))


Solution

  • In addition to @deceze's comment (CET specifies a UTC offset, not a time zone), note that timedelta arithmetic in Python is wall time arithmetic.

    from datetime import datetime, timedelta
    from zoneinfo import ZoneInfo
    
    eu_berlin_tz = ZoneInfo("Europe/Berlin")
    
    start = datetime(2021, 10, 30, 23, 0, tzinfo=eu_berlin_tz)
    
    dur = timedelta(hours=5)
    
    print(start)
    # 2021-10-30 23:00:00+02:00 => CEST
    
    print(start + dur)
    # 2021-10-31 04:00:00+01:00 => CET
    

    2021-10-30T23:00:00+02:00 to 2021-10-31T04:00:00+01:00 is 5 hours on a wall clock, although a duration of 6 hours passed in reality due to DST becoming inactive.

    If you add the duration in UTC and then convert back to the specified time zone, the result is more what you expect IIUC:

    def aware_add(dt, duration):
        return (dt.astimezone(ZoneInfo("UTC")) + duration).astimezone(dt.tzinfo)
    
    print(aware_add(start, dur))
    # 2021-10-31 03:00:00+01:00
    

    2021-10-30T23:00:00+02:00 to 2021-10-31T03:00:00+01:00 is 4 hours on the wall clock but 5 hours in reality.