Handling timezone and DST changes with Python

Alexandra Yovkova
Jan 20, 2022
Categories:Python

Our team had to implement a recurring meetings feature - meetings which happen at the exact same time, each week, for a specific timezone.

For example - schedule a weekly meeting, every Wednesday, at 3pm, Europe/Sofia timezone.

The meeting attendees might be located in different timezones. They expect the meeting dates and times to be adjusted to their own timezone.

The Problem

Working with dates and times when timezones are included is never straightforward.

Here's the outline:

  1. All datetimes are stored in UTC.
  2. We know the timezone of each user.
  3. We need to generate a schedule of weekly recurring meetings, that happen at the same time in the given timezone, regardless of Daylight Saving Time (DST) changes. Meaning - a meeting at 3pm before a DST change should be at 3pm after a DST change.

The initial implementation

We started simple by using pytz, alongside timedelta calculations, to figure out the next meeting datetime.

from datetime import datetime, timedelta

schedule_date = datetime.fromisoformat("2021-10-27T15:00:00-00:00")
next_schedule_date = schedule_date + timedelta(days=7)

If we inspect the values now, they seem correct. Both meetings are set to 15 o'clock (at least in UTC):

>>> schedule_date
datetime.datetime(2021, 10, 26, 15, 0, tzinfo=datetime.timezone.utc)

>>> next_schedule_date
datetime.datetime(2021, 11, 2, 15, 0, tzinfo=datetime.timezone.utc)

>>> schedule_date.hour
15

>>> next_schedule_date.hour
15

But if we localize it to a specific client timezone, it's no longer correct:

>>> from pytz import timezone

>>> client_tz = timezone('Europe/Sofia')

>>> schedule_date.astimezone(client_tz)
datetime.datetime(2021, 10, 26, 18, 0, tzinfo=<DstTzInfo 'Europe/Sofia' EEST+3:00:00 DST>)

>>> next_schedule_date.astimezone(client_tz)
datetime.datetime(2021, 11, 2, 17, 0, tzinfo=<DstTzInfo 'Europe/Sofia' EET+2:00:00 STD>)

The hour for next_schedule_date should be 18 - the correct one, but it actually is 17, which is incorrect.

We can try outsmarting it by adding the timedelta to an already localized datetime:

from datetime import datetime, timedelta
from pytz import timezone

client_tz = timezone('Europe/Sofia')

schedule_date = datetime.fromisoformat("2021-10-27T15:00:00-00:00").astimezone(client_tz)
next_schedule_date = schedule_date + timedelta(days=7)

And if we test it, it might look correct, but it's actually not (look how it changes, when we convert it to client_tz again).

>>> next_schedule_date
datetime.datetime(2021, 11, 2, 18, 0, tzinfo=<DstTzInfo 'Europe/Sofia' EEST+3:00:00 DST>)

>>> next_schedule_date.astimezone(client_tz)
datetime.datetime(2021, 11, 2, 17, 0, tzinfo=<DstTzInfo 'Europe/Sofia' EET+2:00:00 STD>)

So we have the same problem as above - again, we get 17, which is incorrect. The correct hour is 18.

DST change

So why is this happening?

Daylight Saving Time (DST) in the selected timezone (Europe/Sofia) ends on 31.10.2021 at 4:00AM and the clock is moved one hour behind.

This way the hours between 3:00AM and 4:00AM should be calculated twice from the timedelta, but they are not, and the end result is wrong.

Timedelta calculations

Localized datetimes have information about DST and the UTC offset available:

>>> client_tz = timezone('Europe/Sofia')
>>> schedule_date = datetime.fromisoformat("2021-10-26T15:00:00-00:00").astimezone(client_tz)  # DST ends on 31.10

>>> schedule_date.dst()
datetime.timedelta(seconds=3600)

>>> schedule_date.utcoffset()
datetime.timedelta(seconds=10800)

But unfortunately those values are not updated when using timedelta calculations:

>>> schedule_date = schedule_date + timedelta(days=7)

>>> schedule_date.dst()  # DST has ended, the expected result is timedelta(seconds=0)  
datetime.timedelta(seconds=3600) 

>>> schedule_date.utcoffset()  # DST has ended, the expected result is timedelta(seconds=7200). 
datetime.timedelta(seconds=10800) 

In order to handle this, we have to convert the datetime once again to the client_tz before using it:

>>> schedule_date.astimezone(timezone('Europe/Sofia')).dst()
datetime.timedelta(0)

>>> schedule_date.astimezone(timezone('Europe/Sofia')).utcoffset()
datetime.timedelta(seconds=7200)

Generating the correct schedule time

We want to achieve the same localized schedule time for all meetings.

The key thing here is to use the UTC offset between the current and the next meeting.

Let's take a look:

from pytz import timezone
from datetime import datetime, timedelta

client_tz = timezone('Europe/Sofia')

schedule_date = datetime.fromisoformat("2021-10-26T15:00:00-00:00")
# Localize the start date
schedule_date = schedule_date.astimezone(client_tz)

next_schedule_date = schedule_date + timedelta(days=7)
# Make sure the UTC offset and DST info of the calculated result are correct
next_schedule_date = next_schedule_date.astimezone(client_tz)

# Handle DST +-1 hour changes
# This is a timedelta instance
dst_offset_diff = schedule_date.dst() - next_schedule_date.dst()

# Adjust with the offset and convert again to tz
next_schedule_date = next_schedule_date + dst_offset_diff
next_schedule_date = next_schedule_date.astimezone(client_tz)

Now, if we run our tests again:

>>> schedule_date
datetime.datetime(2021, 10, 26, 18, 0, tzinfo=<DstTzInfo 'Europe/Sofia' EEST+3:00:00 DST>)

>>> next_schedule_date
datetime.datetime(2021, 11, 2, 18, 0, tzinfo=<DstTzInfo 'Europe/Sofia' EET+2:00:00 STD>)

>>> next_schedule_date.astimezone(client_tz)
datetime.datetime(2021, 11, 2, 18, 0, tzinfo=<DstTzInfo 'Europe/Sofia' EET+2:00:00 STD>)

We get the correct hour when localized - 18.

So how exactly is the DST ±1h actually handled? We get the difference in DST between the two dates (which is a timedelta) and we just add it to the result. This way, we account for the offset.

If we want to express this as a function, it might look like that:

from datetime import timedelta

def get_next_date(start_dt, tz, days=7):
    start_dt = start_dt.astimezone(tz)
    
    next_dt = start_dt + timedelta(days=days)
    next_dt = next_dt.astimezone(tz)
    
    dst_offset_diff = start_dt.dst() - next_dt.dst()
    
    next_dt = next_dt + dst_offset_diff
    next_dt = next_dt.astimezone(tz)
    
    return next_dt

Wrong way of fixing datetimes

Previously we covered the problem of handling the DST change resulting in wrong meeting hours.

One incorrect solution to this is to directly set the hour to the one we initially had. As this might work in some cases, it is not generally correct.

Let's create a meeting for 00:30, Wednesday 27.10.2021 Europe/Sofia timezone.

Directly setting the hour=0 for the next meeting (when DST has ended) will produce a meeting scheduled for a day earlier than expected:

>>> from datetime import datetime, timedelta
>>> from pytz import timezone

>>> client_tz = timezone('Europe/Sofia')

>>> schedule_date = datetime.fromisoformat("2021-10-26T21:30:00-00:00")
>>> schedule_date.astimezone(client_tz)
datetime.datetime(2021, 10, 27, 0, 30, tzinfo=<DstTzInfo 'Europe/Sofia' EEST+3:00:00 DST>)
 
>>> next_schedule_date = schedule_date + timedelta(days=7)

>>> next_schedule_date.astimezone(client_tz)
datetime.datetime(2021, 11, 2, 23, 30, tzinfo=<DstTzInfo 'Europe/Sofia' EET+2:00:00 STD>)

>>> next_schedule_date.astimezone(client_tz).replace(hour=0)
datetime.datetime(2021, 11, 2, 0, 30, tzinfo=<DstTzInfo 'Europe/Sofia' EET+2:00:00 STD>)

The correct expected date should be 3rd of November, but we get the 2nd of November.

Dateutil.rrule

When we initially started researching the topic, we thought about using something really simple - a util that generates a list of datetime objects for a period of time.

One of the most popular existing such tools for Python is the dateutil.rrule. It has a really simple interface and looks like it does everything we need. We thought of using it when we started implementing the feature.

We dropped the idea quickly.

As described in their documentation - the generated objects might not be valid. The invalid ones are ignored and this might lead to missing instances which is unwanted behavior.

Having all of this in mind, we decided to implement the utility ourselves.

Conclusion

Be careful when handling any of these in your Python code: