Search code examples
pythondatetimetimezonepytz

Dates and time zone codes around a DST change


I'm testing how dates are calculated and displayed (with time zone codes) around a daylight savings change.

In the UK, at 1am on the 30th March 2014, we enter DST, and go from GMT to BST. The time jumps from 2014-03-30 00:59:59 GMT to 2014-03-30 02:00:00 BST.

I've hit a strange issue replicating this with the following code:

import pytz
from datetime import datetime, time, timedelta

def is_dst(d, tz):
    assert d.tzinfo is None  # we want a naive datetime to localize
    return tz.localize(d).dst() != timedelta(0)

start_datetime = datetime(2014, 03, 30, 0, 0, 0)
tz = pytz.timezone('Europe/London')

# Increment using timedelta
print 'This doesn\'t work:'
d = start_datetime
for i in range(5):
    print str(d) + ' ' + tz.tzname(d, is_dst=is_dst(d, tz))
    d += timedelta(minutes=30)  # Add 30 minutes

# Increment by adding seconds to epoch
print 'This works:'
epoch = datetime.utcfromtimestamp(0)
timestamp = (start_datetime - epoch).total_seconds()
for i in range(5):
    d = datetime.fromtimestamp(timestamp)
    print str(d) + ' ' + tz.tzname(d, is_dst=is_dst(d, tz))
    timestamp += 30 * 60  # Add 30 minutes

The output is:

This doesn't work:
2014-03-30 00:00:00 GMT
2014-03-30 00:30:00 GMT
2014-03-30 01:00:00 GMT <- invalid time
2014-03-30 01:30:00 GMT <- invalid time
2014-03-30 02:00:00 BST
This works:
2014-03-30 00:00:00 GMT
2014-03-30 00:30:00 GMT
2014-03-30 02:00:00 BST
2014-03-30 02:30:00 BST
2014-03-30 03:00:00 BST

I have marked on the output where the invalid times are. Those times do not exist on the wallclock, there is no 1am or 1:30am on the 30th March 2014, so I'm not sure why it is being displayed.

The same process but done in a slightly different way yields the correct results. Why is this?


Solution

  • Actually, both sections of code are incorrect. Running your exact code on my machine (in US Pacific Time zone), the bottom section returns:

    2014-03-29 17:00:00 GMT
    2014-03-29 17:30:00 GMT
    2014-03-29 18:00:00 GMT
    2014-03-29 18:30:00 GMT
    2014-03-29 19:00:00 GMT
    

    This is because fromtimestamp uses the computer's local time zone when none is specified. If I just switch the fromtimestamp to utcfromtimestamp, it will use a naive value - The results then get put in the correct time zone, but it gives the same results as the first section - showing the two invalid times.

    The problem is rectified by using the normalize method from the pytz timezone instance. Unfortunately, you're keeping d as a naive datetime, so you can't normalize it without localizing it first, and when done you'll have to make it naive again. This works, but makes for some messy code:

    # Increment using timedelta
    print 'This works:'
    d = start_datetime
    for i in range(5):
        print str(d) + ' ' + tz.tzname(d, is_dst=is_dst(d, tz))
        d += timedelta(minutes=30)  # Add 30 minutes
        d = tz.normalize(tz.localize(d)).replace(tzinfo=None)
    
    # Increment by adding seconds to epoch
    print 'This works too:'
    epoch = datetime.utcfromtimestamp(0)
    timestamp = (start_datetime - epoch).total_seconds()
    for i in range(5):
        d = tz.normalize(datetime.fromtimestamp(timestamp, pytz.utc).astimezone(tz)).replace(tzinfo=None)
        print str(d) + ' ' + tz.tzname(d, is_dst=is_dst(d, tz))
        timestamp += 30 * 60  # Add 30 minutes
    

    Output:

    This works:
    2014-03-30 00:00:00 GMT
    2014-03-30 00:30:00 GMT
    2014-03-30 02:00:00 BST
    2014-03-30 02:30:00 BST
    2014-03-30 03:00:00 BST
    This works too:
    2014-03-30 00:00:00 GMT
    2014-03-30 00:30:00 GMT
    2014-03-30 02:00:00 BST
    2014-03-30 02:30:00 BST
    2014-03-30 03:00:00 BST
    

    Of course, the whole thing could be simplified by using aware datetimes early:

    import pytz
    from datetime import datetime, time, timedelta
    
    tz = pytz.timezone('Europe/London')
    start_datetime = tz.localize(datetime(2014, 03, 30, 0, 0, 0))
    
    # Increment using timedelta
    print 'This works:'
    d = start_datetime
    for i in range(5):
        print str(d) + ' ' + d.tzname()
        d = tz.normalize(d + timedelta(minutes=30))  # Add 30 minutes
    
    # Increment by adding seconds to epoch
    print 'This works too:'
    epoch = datetime.fromtimestamp(0, pytz.utc)
    timestamp = (start_datetime - epoch).total_seconds()
    for i in range(5):
        d = datetime.fromtimestamp(timestamp, pytz.utc).astimezone(tz)
        print str(d) + ' ' + d.tzname()
        timestamp += 30 * 60  # Add 30 minutes
    

    Output:

    This works:
    2014-03-30 00:00:00+00:00 GMT
    2014-03-30 00:30:00+00:00 GMT
    2014-03-30 02:00:00+01:00 BST
    2014-03-30 02:30:00+01:00 BST
    2014-03-30 03:00:00+01:00 BST
    This works too:
    2014-03-30 00:00:00+00:00 GMT
    2014-03-30 00:30:00+00:00 GMT
    2014-03-30 02:00:00+01:00 BST
    2014-03-30 02:30:00+01:00 BST
    2014-03-30 03:00:00+01:00 BST
    

    Note that you no longer need the is_dst function, as you can now get the tzname directly from the aware datetime instance.