pythondatetimetimezonetimedeltadst

Adding timedelta to local datetime, unexpected behaviour across DST shift


I just stumbled accross this surprising behaviour with Python datetimes while creating datetimes across DST shift.

Adding a timedelta to a local datetime might not add the amount of time we expect.

import datetime as dt
from zoneinfo import ZoneInfo

# Midnight
d0 = dt.datetime(2020, 3, 29, 0, 0, tzinfo=ZoneInfo("Europe/Paris"))
# datetime.datetime(2020, 3, 29, 0, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Paris'))
d0.isoformat()
# '2020-03-29T00:00:00+01:00'

# Before DST shift
d1 = d0 + dt.timedelta(hours=2)
# datetime.datetime(2020, 3, 29, 2, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Paris'))
d1.isoformat()
# '2020-03-29T02:00:00+01:00'

# After DST shift
d2 = d0 + dt.timedelta(hours=3)
# datetime.datetime(2020, 3, 29, 3, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Paris'))
d2.isoformat()
# '2020-03-29T03:00:00+02:00'

# Convert to UCT
d1u = d1.astimezone(dt.timezone.utc)
# datetime.datetime(2020, 3, 29, 1, 0, tzinfo=datetime.timezone.utc)
d2u = d2.astimezone(dt.timezone.utc)
# datetime.datetime(2020, 3, 29, 1, 0, tzinfo=datetime.timezone.utc)

# Compute timedeltas
d2 - d1
# datetime.timedelta(seconds=3600)
d2u - d1u
# datetime.timedelta(0)

I agree d1 and d2 are the same, but shouldn't d2 be '2020-03-29T04:00:00+02:00', then?

d3 = d0 + dt.timedelta(hours=4)
# datetime.datetime(2020, 3, 29, 4, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/Paris'))

Apparently, when adding a timedelta (e.g. 3 hours) to a local datetime, it is added regardless of the timezone and the delta between the two datetimes (in real time / UTC) is not guaranteed to be that timedelta (i.e. it may be 2 hours due to DST). This is a bit of a pitfall.

What is the rationale? Is this documented somewhere?


Solution

  • The rationale is : timedelta arithmetic is wall time arithmetic. That is, it includes the DST transition hours (or excludes, depending on the change). See also P. Ganssle's blog post on the topic .

    An illustration:

    import datetime as dt
    from zoneinfo import ZoneInfo
    
    # Midnight
    d0 = dt.datetime(2020, 3, 29, 0, 0, tzinfo=ZoneInfo("Europe/Paris"))
    
    for h in range(1, 4):
        print(h)
        print(d0 + dt.timedelta(hours=h))
        print((d0 + dt.timedelta(hours=h)).astimezone(ZoneInfo("UTC")), end="\n\n")
    
    1
    2020-03-29 01:00:00+01:00
    2020-03-29 00:00:00+00:00 # as expected, 1 hour added
    
    2
    2020-03-29 02:00:00+01:00 # that's a non-existing datetime...
    2020-03-29 01:00:00+00:00 # looks normal
    
    3
    2020-03-29 03:00:00+02:00
    2020-03-29 01:00:00+00:00 # oops, 3 hours timedelta is only 2 hours actually!
    

    Need more confusion? Use naive datetime. Given that the tz of my machine (Europe/Berlin) has the same DST transitions as the tz used above:

    d0 = dt.datetime(2020, 3, 29, 0, 0)
    
    for h in range(1, 4):
        print(h)
        print(d0 + dt.timedelta(hours=h))
        print((d0 + dt.timedelta(hours=h)).astimezone(ZoneInfo("UTC")), end="\n\n")
    
    1
    2020-03-29 01:00:00       # 1 hour as expected
    2020-03-29 00:00:00+00:00 # we're on UTC+1
    
    2
    2020-03-29 02:00:00       # ok 2 hours...
    2020-03-29 00:00:00+00:00 # wait, what?!
    
    3
    2020-03-29 03:00:00
    2020-03-29 01:00:00+00:00