Python has two modes of dealing with dates and times: Timezone-naive and timezone-aware. The former is simpler, the latter is more powerful but has some pitfalls in store. This article summarizes a few edge cases involving daylight saving shifts where timezone-aware datetime objects behave in unexpected ways. At the time of writing, according to a non-representative quiz I launched online, only 20 % of all 354 responses were correct. Let’s see what the problem is.

The problem

The problem is buried in a footnote in the Python documentation detailing arithmetic operations on datetime objects.

Subtraction of a datetime from a datetime is defined only if both operands are naive, or if both are aware. […] If both are naive, or both are aware and have the same tzinfo attribute, the tzinfo attributes are ignored, […].

This means that if you subtract two timezone-aware datetime objects in the same timezone, the timezone information is ignored. The problem arises if two datetime objects have the same timezone but due to the daylight saving shift, their offsets to UTC differ. Let’s say we have two datetime objects, one at 9 pm the evening before daylight saving time ends, and one at 9 am the next day. A person with a stopwatch would measure 13 hours between the two events. However, since Python ignores the timezone information, it will report 12 hours.

The rationale behind this is, that if you want to schedule a task every day at 10 am in the local timezone, you can do so by taking one event at 10 am and repeatedly add 24 hours. The resulting time will always be 10 am, even if the clock is shifted due to daylight saving time.

In my view, this is a fundamental design flaw in Python’s datetime module. The module should differentiate between adding a full day or adding 24 hours. Adding a full day would ignore daylight saving shifts. While repeatably adding a full day, would keep tasks at the same local time, adding 24 hours would keep the time observed with a stopwatch consistent. The worst part is that the problem is only visible in edge cases and only specified in a footnote in the documentation.

How to avoid it

There are two simple ways to avoid the problem:

  • Always convert to UTC when doing arithmetic operations.
  • Avoid using the datetime library and use a library like pendulum.

Examples with surprising results

Summary

  • The additional hour of sleep at the end of daylight saving time is not counted.
  • The additional hour of sleep at the end of daylight saving time is counted if two different timezones with identical DST logic are used (e.g. Berlin and Paris).
  • Adding a day and 24 hours is the same across daylight saving shifts.
  • Points in time are incorrectly ordered across daylight saving shifts.

Amount of sleep during daylight saving switch

In Europe, daylight saving time ends early in the morning on Sunday, October 27, 2024. At 3:00 AM, clocks are set back to 2:00 AM, adding an extra hour to the night. During daylight saving, Central European Summer Time (CEST) is UTC +2 hours. After the switch, Central European Time (CET) is UTC +1 hour.

Let’s calculate how many hours of sleep someone in Germany would get if they went to bed on Saturday at 9:00 PM (CEST) and set their alarm for 9:00 AM (CET) the next morning.

from zoneinfo import ZoneInfo
from datetime import datetime

berlin = ZoneInfo("Europe/Berlin")
sat = datetime(2024, 10, 26, 21, 0, tzinfo=berlin)
sun = datetime(2024, 10, 27, 9, 0, tzinfo=berlin)

assert sat.isoformat() == '2024-10-26T21:00:00+02:00'
assert sun.isoformat() == '2024-10-27T09:00:00+01:00'

diff = sun - sat
assert diff.total_seconds() == 12 * 3600

Python computes 12 hours of sleep, however, a person with a stopwatch would measure 13 hours.

Bus ride from Berlin to Paris across a daylight saving switch

Now, instead of going to bed and setting an alarm, let’s consider a person who boards a bus in Berlin on Saturday at 9:00 PM (CEST) and arrives in Paris on Sunday at 9:00 AM (CET).

Both Germany and France share the same UTC offset and follow identical daylight saving time rules.

from zoneinfo import ZoneInfo
from datetime import datetime

berlin = ZoneInfo("Europe/Berlin")
sat = datetime(2024, 10, 26, 21, 0, tzinfo=berlin)

paris = ZoneInfo("Europe/Paris")
sun = datetime(2024, 10, 27, 9, 0, tzinfo=paris)

assert sat.isoformat() == '2024-10-26T21:00:00+02:00'
assert sun.isoformat() == '2024-10-27T09:00:00+01:00'

diff = sun - sat
assert diff.total_seconds() == 13 * 3600

In contrast to the previous example, Python computes 13 hours of travel time. This is because formally Berlin and Paris is not the same timezone object.

Adding a day and 24 hours is the same

from zoneinfo import ZoneInfo
from datetime import datetime, timedelta

berlin = ZoneInfo("Europe/Berlin")
sat = datetime(2024, 10, 26, 21, 0, tzinfo=berlin)

a = sat + timedelta(days=1)
b = sat + timedelta(hours=24)

Adding a day and 24 hours to the same time yields the same result, 9 pm on Sunday. However, due to the daylight saving shift, one might expect that adding 24 hours would yield 8 pm instead.

Incorrect ordering

From GitHub issue python/cpython#116111.

from zoneinfo import ZoneInfo
from datetime import datetime, timedelta, timezone

berlin = ZoneInfo("Europe/Berlin")
a = datetime(2024, 10, 27, 2, 30, fold=1, tzinfo=berlin)
b = datetime(2024, 10, 27, 2, 35, fold=0, tzinfo=berlin)

assert a < b

a.astimezone(timezone.utc).isoformat()  # 2024-10-27T01:30:00+00:00
b.astimezone(timezone.utc).isoformat()  # 2024-10-27T00:35:00+00:00

The point a is on the second fold, i.e. after the daylight saving shift with UTC offset +1, while b is on the first fold, i.e. before the shift with UTC offset +2. The two points are unambiguously ordered in UTC, b occurs before a, however, Python incorrectly orders the two times.

Is this a bug?

The behavior is as specified in the documentation, so it is not a bug. The problem is that this detail is hidden in a footnote, not well known, and even misleadingly described in the zoneinfo documentation. Help spread awareness of this issue.