Time Zone Troubles
Dealing with Ambiguous and Imaginary Datetimes


Paul Ganssle



Github repo for this talk

In [1]:
%load_ext autoreload
%autoreload 2


The autoreload extension is already loaded. To reload it, use:
  %reload_ext autoreload

In [2]:
import pytz

In [3]:
from dateutil.tz import gettz, tzutc
from dateutil import tz
from datetime import datetime, timedelta, tzinfo

In [4]:
from dateutil.tz import gettz
from dateutil.tz import tzutc, tzoffset, tzlocal
from dateutil.tz import tzstr, tzrange, tzfile

from dateutil import relativedelta as rd

In [5]:
from helper_functions import print_tzinfo
from helper_functions import print_dt_eq

In [6]:
def add_absolute(dt, td):
    if dt.tzinfo is None:
        return dt + td
    
    dtu = dt.astimezone(tz.tzutc())
    return (dtu + td).astimezone(dt.tzinfo)

In [7]:
NYC = gettz('America/New_York')
CHI = gettz('America/Chicago')
UTC = tzutc()

Introduction

UTC

  • Coordinated Universal Time / Temps Universel Coordonné
  • Also called Greenwich Mean Time (GMT)

Time zones vs. Offsets

  • UTC-6 is an offset
  • US/Central is a time zone
  • CST is a highly-context-dependent abbreviation:
    • Central Standard Time (UTC-6)
    • Cuba Standard Time (UTC-5)
    • China Standard Time (UTC+8)

Complicated time zones

Non-integer offsets

Examples:

  • Australia/Adelaide (+09:30)
  • Asia/Kathmandu (+05:45)
  • Africa/Monrovia (+00:44:30) (Before 1979)

Change of DST status without offset change

  • Portugal, 1992

    • WET (+0 STD) -> WEST (+1 DST) 1992-03-29
    • WEST (+1 DST) -> CET (+1 STD) 1992-09-27
  • Portugal, 1996

    • CET (+1 STD) -> WEST (+1 DST) 1996-03-31
    • WEST (+1 DST) -> WET (+0 STD) 1996-10-27

Complicated time zones

Zone name change without offset change

  • Aleutian Islands, 1983:
    • BST (-11 STD) -> BDT (-10 DST), 1983-04-24
    • BDT (-10 DST) -> AHST (-10 STD), 1983-10-30
    • AHST (-10 STD) -> HST (-10 STD), 1983-11-30 (Zone renamed)

More than one DST transition per year

  • Morroco, 2012
    • WET (+0 STD) -> WEST (+1 DST) 2012-04-29
    • WEST (+1 DST) -> WET (+0 STD) 2012-07-20
    • WET (+0 STD) -> WEST (+1 DST) 2012-08-20
    • WEST (+1 DST) -> WET (+0 STD) 2012-09-30

... and Morocco in 2013-present, and Egypt in 2010 and 2014, and Palestine in 2011.

Complicated time zones

Missing days

  • Christmas Island (Kiritimati), January 2, 1995 (UTC-10 -> UTC+14)

In [8]:
dt_before = datetime(1995, 1, 1, 23, 59, tzinfo=tz.gettz('Pacific/Kiritimati'))
dt_after = add_absolute(dt_before, timedelta(minutes=2))

print(dt_before)
print(dt_after)


1995-01-01 23:59:00-10:00
1995-01-03 00:01:00+14:00

Also Samoa on January 29, 2011.

Double days

  • Kwajalein Atoll, 1969

In [9]:
dt_before = datetime(1969, 9, 30, 11, 59, tzinfo=tz.gettz('Pacific/Kwajalein'))
dt_after = add_absolute(dt_before, timedelta(minutes=2))

print(dt_before)
print(dt_after)


1969-09-30 11:59:00+11:00
1969-09-30 12:01:00-12:00

Why do we need to work with time zones at all?


In [10]:
from dateutil import rrule as rr

# Close of business in New York on weekdays
closing_times = rr.rrule(freq=rr.DAILY, byweekday=(rr.MO, rr.TU, rr.WE, rr.TH, rr.FR),
                         byhour=17, dtstart=datetime(2017, 3, 9, 17), count=5)

for dt in closing_times:
    print(dt.replace(tzinfo=NYC))


2017-03-09 17:00:00-05:00
2017-03-10 17:00:00-05:00
2017-03-13 17:00:00-04:00
2017-03-14 17:00:00-04:00
2017-03-15 17:00:00-04:00

In [11]:
for dt in closing_times:
    print(dt.replace(tzinfo=NYC).astimezone(UTC))


2017-03-09 22:00:00+00:00
2017-03-10 22:00:00+00:00
2017-03-13 21:00:00+00:00
2017-03-14 21:00:00+00:00
2017-03-15 21:00:00+00:00

Python's Time Zone Model

tzinfo

  • Time zones are provided by subclassing tzinfo.
  • Information provided is a function of the datetime:

    • tzname: The (usually abbreviated) name of the time zone at the given datetime
    • utcoffset: The offset from UTC at the given datetime
    • dst: The size of the datetime's DST offset (usually 0 or 1 hour)

An example tzinfo implementation


In [12]:
class ET(tzinfo):
    def utcoffset(self, dt):
        if self.isdaylight(dt):
            return timedelta(hours=-4)
        else:
            return timedelta(hours=-5)
    
    def dst(self, dt):
        if self.isdaylight(dt):
            return timedelta(hours=1)
        else:
            return timedelta(hours=0)
    
    def tzname(self, dt):
        return "EDT" if self.isdaylight(dt) else "EST"

    def isdaylight(self, dt):
        dst_start = datetime(dt.year, 1, 1) + rd.relativedelta(month=3, weekday=rd.SU(+2), hour=2)
        dst_end = datetime(dt.year, 1, 1) + rd.relativedelta(month=11, weekday=rd.SU, hour=2)
        
        return dst_start <= dt.replace(tzinfo=None) < dst_end

print(datetime(2017, 11, 4, 12, 0, tzinfo=ET()))
print(datetime(2017, 11, 5, 12, 0, tzinfo=ET()))


2017-11-04 12:00:00-04:00
2017-11-05 12:00:00-05:00

In [13]:
dt_before_utc = datetime(2017, 11, 5, 0, 30, tzinfo=ET()).astimezone(tz.tzutc())
dt_during = (dt_before_utc + timedelta(hours=1)).astimezone(ET())  # 1:30 EST
dt_after = (dt_before_utc + timedelta(hours=2)).astimezone(ET())   # 1:30 EDT

print(dt_during)   # Lookin good!
print(dt_after)    # OH NO!


2017-11-05 01:30:00-04:00
2017-11-05 02:30:00-05:00

Ambiguous times

Ambiguous times are times where the same "wall time" occurs twice, such as during a DST to STD transition.


In [14]:
dt1 = datetime(2004, 10, 31, 4, 30, tzinfo=UTC)
for i in range(4):
    dt = (dt1 + timedelta(hours=i)).astimezone(NYC)
    print('{} | {} |  {}'.format(dt, dt.tzname(), 
                                   'Ambiguous' if tz.datetime_ambiguous(dt) else 'Unambiguous'))


2004-10-31 00:30:00-04:00 | EDT |  Unambiguous
2004-10-31 01:30:00-04:00 | EDT |  Ambiguous
2004-10-31 01:30:00-05:00 | EST |  Ambiguous
2004-10-31 02:30:00-05:00 | EST |  Unambiguous

PEP-495: Local Time Disambiguation

  • First introduced in Python 3.6
  • Introduces the fold attribute of datetime
  • Changes to aware datetime comparison around ambiguous times

Whether you are on the fold side is a property of the datetime:


In [15]:
print_tzinfo(datetime(2004, 10, 31, 1, 30, tzinfo=NYC))          # fold=0
print_tzinfo(datetime(2004, 10, 31, 1, 30, fold=1, tzinfo=NYC))


2004-10-31 01:30:00-0400
    tzname:   EDT;      UTC Offset:  -4.00h;        DST:      1.0h
2004-10-31 01:30:00-0500
    tzname:   EST;      UTC Offset:  -5.00h;        DST:      0.0h

Note: fold=1 represents the second instance of an ambiguous datetime

Comparing timezone-aware datetimes


In [16]:
dt1 = datetime(2004, 10, 30, 12, 0);   dt1a = datetime(2004, 10, 31, 1, 30)
dt2 = datetime(2004, 10, 30, 12, 0);   dt2a = datetime(2004, 10, 31, 1, 30)
dt3 = datetime(2004, 10, 30, 11, 0);   dt3a = datetime(2004, 10, 31, 2, 30)   # Unambiguous
  • Same Zone: Wall clock times are used, offset ignored

In [17]:
print_dt_eq(dt1.replace(tzinfo=NYC), dt2.replace(tzinfo=NYC))   # Unambiguous
print_dt_eq(dt1.replace(tzinfo=NYC), dt3.replace(tzinfo=NYC))


2004-10-30 12:00:00-04:00 == 2004-10-30 12:00:00-04:00: True
2004-10-30 12:00:00-04:00 == 2004-10-30 11:00:00-04:00: False

In [18]:
print_dt_eq(dt1a.replace(tzinfo=NYC), dt2a.replace(tzinfo=NYC))  # Ambiguous
print_dt_eq(dt1a.replace(tzinfo=NYC), dt2a.replace(fold=1, tzinfo=NYC), bold=True)
print_dt_eq(dt1a.replace(tzinfo=NYC), dt3a.replace(tzinfo=NYC))


2004-10-31 01:30:00-04:00 == 2004-10-31 01:30:00-04:00: True
2004-10-31 01:30:00-04:00 == 2004-10-31 01:30:00-05:00: True
2004-10-31 01:30:00-04:00 == 2004-10-31 02:30:00-05:00: False

Comparing timezone-aware datetimes

  • Different zones: If both datetimes are unambiguous, the absolute times are compared:

In [19]:
print_dt_eq(dt1.replace(tzinfo=NYC), dt2.replace(tzinfo=CHI))    # Unambiguous
print_dt_eq(dt1.replace(tzinfo=NYC), dt3.replace(tzinfo=CHI))


2004-10-30 12:00:00-04:00 == 2004-10-30 12:00:00-05:00: False
2004-10-30 12:00:00-04:00 == 2004-10-30 11:00:00-05:00: True

If either datetime is ambiguous, the result is always False:


In [20]:
print_dt_eq(dt1a.replace(fold=1, tzinfo=NYC), dt3a.replace(tzinfo=CHI), bold=True)


2004-10-31 01:30:00-05:00 == 2004-10-31 02:30:00-06:00: False

A curious case...


In [21]:
LON = gettz('Europe/London')

x = datetime(2007, 3, 25, 1, 0, tzinfo=LON)
ts = x.timestamp()
y = datetime.fromtimestamp(ts, LON)
z = datetime.fromtimestamp(ts, gettz('Europe/London'))

In [22]:
x == y


Out[22]:
False

In [23]:
x == z


Out[23]:
True

In [24]:
y == z


Out[24]:
True

Imaginary Times

Imaginary times are wall times that don't exist in a given time zone, such as during an STD to DST transition.


In [25]:
dt1 = datetime(2004, 4, 4, 6, 30, tzinfo=UTC)
for i in range(3):
    dt = (dt1 + timedelta(hours=i)).astimezone(NYC)
    print('{} | {} '.format(dt, dt.tzname()))


2004-04-04 01:30:00-05:00 | EST 
2004-04-04 03:30:00-04:00 | EDT 
2004-04-04 04:30:00-04:00 | EDT 

In [26]:
print(datetime(2007, 3, 25, 1, 0, tzinfo=LON))


2007-03-25 01:00:00+01:00

In [27]:
print(datetime(2007, 3, 25, 0, 0, tzinfo=UTC).astimezone(LON))
print(datetime(2007, 3, 25, 1, 0, tzinfo=UTC).astimezone(LON))


2007-03-25 00:00:00+00:00
2007-03-25 02:00:00+01:00

Why it was non-transitive


In [28]:
LON = gettz('Europe/London')

x = datetime(2007, 3, 25, 1, 0, tzinfo=LON)
ts = x.timestamp()
y = datetime.fromtimestamp(ts, LON)
z = datetime.fromtimestamp(ts, gettz('Europe/London'))

In [29]:
print('x (LON):              {}'.format(x))
print('x (UTC):              {}'.format(x.astimezone(UTC)))
print('x (LON->UTC->LON):    {}'.format(x.astimezone(UTC).astimezone(LON)))


x (LON):              2007-03-25 01:00:00+01:00
x (UTC):              2007-03-25 00:00:00+00:00
x (LON->UTC->LON):    2007-03-25 00:00:00+00:00

In [30]:
print('y: {}'.format(y))
print('z: {}'.format(z))


y: 2007-03-25 00:00:00+00:00
z: 2007-03-25 00:00:00+00:00

In [31]:
print('x: {}'.format(x))
print('y: {}'.format(y))
print('z: {}'.format(z))


x: 2007-03-25 01:00:00+01:00
y: 2007-03-25 00:00:00+00:00
z: 2007-03-25 00:00:00+00:00

In [32]:
x.tzinfo is y.tzinfo


Out[32]:
True

In [33]:
x.tzinfo is z.tzinfo


Out[33]:
False

Working with time zones

dateutil

In dateutil's suite of tzinfo objects, you can attach time zones in the constructor if you have a wall time:


In [34]:
dt = datetime(2017, 8, 11, 14, tzinfo=tz.gettz('US/Pacific'))
print_tzinfo(dt)


2017-08-11 14:00:00-0700
    tzname:   PDT;      UTC Offset:  -7.00h;        DST:      1.0h

If you have a naive wall time, or a wall time in another zone that you want to translate without shifting the offset, use datetime.replace:


In [35]:
print_tzinfo(dt.replace(tzinfo=tz.gettz('US/Eastern')))


2017-08-11 14:00:00-0400
    tzname:   EDT;      UTC Offset:  -4.00h;        DST:      1.0h

If you have an absolute time, in UTC or otherwise, use datetime.astimezone():


In [36]:
print_tzinfo(dt.astimezone(tz.gettz('US/Eastern')))


2017-08-11 17:00:00-0400
    tzname:   EDT;      UTC Offset:  -4.00h;        DST:      1.0h

pytz

In pytz, datetime.astimezone() still works exactly as expected:


In [37]:
print_tzinfo(dt.astimezone(pytz.timezone('US/Eastern')))


2017-08-11 17:00:00-0400
    tzname:   EDT;      UTC Offset:  -4.00h;        DST:      1.0h

But the constructor or .replace methods fail horribly:


In [38]:
print_tzinfo(dt.replace(tzinfo=pytz.timezone('US/Eastern')))


2017-08-11 14:00:00-0456
    tzname:   LMT;      UTC Offset:  -4.93h;        DST:      0.0h

pytz's time zone model

  • tzinfos are all static offsets
  • tzinfo is attached by the time zone object itself:

In [39]:
LOS_p = pytz.timezone('America/Los_Angeles')
dt = LOS_p.localize(datetime(2017, 8, 11, 14, 0))
print_tzinfo(dt)


2017-08-11 14:00:00-0700
    tzname:   PDT;      UTC Offset:  -7.00h;        DST:      1.0h
  • You must normalize() datetimes after you've done some arithmetic on them:

In [40]:
dt_add = dt + timedelta(days=180)
print_tzinfo(dt_add)


2018-02-07 14:00:00-0700
    tzname:   PDT;      UTC Offset:  -7.00h;        DST:      1.0h

In [41]:
print_tzinfo(LOS_p.normalize(dt_add))


2018-02-07 13:00:00-0800
    tzname:   PST;      UTC Offset:  -8.00h;        DST:      0.0h

Handling ambiguous times

Overview

Both dateutil and pytz will automatically give you the right absolute time if converting from an absolute time.


In [42]:
dt1 = datetime(2004, 10, 31, 6, 30, tzinfo=UTC)  # This is in the fold in EST

dt_dateutil = dt1.astimezone(tz.gettz('US/Eastern'))
dt_pytz = dt1.astimezone(pytz.timezone('US/Eastern'))
print(repr(dt_dateutil))
print_tzinfo(dt_dateutil)


datetime.datetime(2004, 10, 31, 1, 30, fold=1, tzinfo=tzfile('/usr/share/zoneinfo/US/Eastern'))
2004-10-31 01:30:00-0500
    tzname:   EST;      UTC Offset:  -5.00h;        DST:      0.0h

In [43]:
print(repr(dt_pytz))    # Note that pytz doesn't set fold
print_tzinfo(dt_pytz)


datetime.datetime(2004, 10, 31, 1, 30, tzinfo=<DstTzInfo 'US/Eastern' EST-1 day, 19:00:00 STD>)
2004-10-31 01:30:00-0500
    tzname:   EST;      UTC Offset:  -5.00h;        DST:      0.0h

dateutil

For backwards compatibility, dateutil provides a tz.enfold method to add a fold attribute if necessary:


In [44]:
dt = datetime(2004, 10, 31, 1, 30, tzinfo=tz.gettz('US/Eastern'))
tz.enfold(dt)


Out[44]:
datetime.datetime(2004, 10, 31, 1, 30, fold=1, tzinfo=tzfile('/usr/share/zoneinfo/US/Eastern'))
Python 2.7.12
Type "help", "copyright", "credits" or "license" for more information.
>>> from datetime import datetime
>>> from dateutil import tz
>>> dt = datetime(2004, 10, 31, 1, 30, tzinfo=tz.gettz('US/Eastern'))
>>> tz.enfold(dt)
_DatetimeWithFold(2004, 10, 31, 1, 30, tzinfo=tzfile('/usr/share/zoneinfo/US/Eastern'))
>>> tz.enfold(dt).tzname()
'EST'
>>> dt.tzname()
'EDT'

dateutil

To detect ambiguous times, dateutil provides tz.datetime_ambiguous


In [45]:
tz.datetime_ambiguous(datetime(2004, 10, 31, 1, 30, tzinfo=NYC))


Out[45]:
True

In [46]:
tz.datetime_ambiguous(datetime(2004, 10, 31, 1, 30), NYC)


Out[46]:
True

In [47]:
dt_0 = datetime(2004, 10, 31, 0, 30, tzinfo=NYC)
for i in range(3):
    dt_i = dt_0 + timedelta(hours=i)
    dt_i = tz.enfold(dt_i, tz.datetime_ambiguous(dt_i))
    print('{} (fold={})'.format(dt_i, dt_i.fold))


2004-10-31 00:30:00-04:00 (fold=0)
2004-10-31 01:30:00-05:00 (fold=1)
2004-10-31 02:30:00-05:00 (fold=0)

Note: fold is ignored when the datetime is not ambiguous:


In [48]:
for i in range(3):
    dt_i = tz.enfold(dt_0 + timedelta(hours=i), fold=1)
    print('{} (fold={})'.format(dt_i, dt_i.fold))


2004-10-31 00:30:00-04:00 (fold=1)
2004-10-31 01:30:00-05:00 (fold=1)
2004-10-31 02:30:00-05:00 (fold=1)

pytz

When localizing times, pytz defaults to standard time:


In [49]:
NYC_pytz = pytz.timezone('America/New_York')

In [50]:
dt_pytz = NYC_pytz.localize(datetime(2004, 10, 31, 1, 30))
print_tzinfo(dt_pytz)


2004-10-31 01:30:00-0500
    tzname:   EST;      UTC Offset:  -5.00h;        DST:      0.0h

To get a time zone in daylight time, pass is_dst=True to localize:


In [51]:
dt_pytz = NYC_pytz.localize(datetime(2004, 10, 31, 1, 30), is_dst=True)
print_tzinfo(dt_pytz)


2004-10-31 01:30:00-0400
    tzname:   EDT;      UTC Offset:  -4.00h;        DST:      1.0h

If is_dst=None is passed to localize, pytz raises an AmbiguousTimeError:


In [52]:
for hour in (0, 1):
    dt = datetime(2004, 10, 31, hour, 30)
    try:
        NYC_pytz.localize(dt, is_dst=None)
        print('{} | {}'.format(dt, "Unambiguous"))
    except pytz.AmbiguousTimeError:
        print('{} | {}'.format(dt, "Ambiguous"))


2004-10-31 00:30:00 | Unambiguous
2004-10-31 01:30:00 | Ambiguous

Handling imaginary times

dateutil

dateutil provides a tz.datetime_exists() function to tell you whether you've constructed an imaginary datetime:


In [53]:
dt_0 = datetime(2004, 4, 4, 1, 30, tzinfo=NYC)
for i in range(3):
    dt = dt_0 + timedelta(hours=i)
    print('{} ({})'.format(dt, 'Exists' if tz.datetime_exists(dt) else 'Imaginary'))


2004-04-04 01:30:00-05:00 (Exists)
2004-04-04 02:30:00-04:00 (Imaginary)
2004-04-04 03:30:00-04:00 (Exists)

Generally for imaginary datetimes, you either want to skip over them or "slide forward":


In [54]:
def resolve_imaginary(dt):          # This is a planned feature in dateutil 2.7.0
    if dt.tzinfo is not None and not tz.datetime_exists(dt):
        curr_offset = dt.utcoffset()
        old_offset = (dt - timedelta(hours=24)).utcoffset()
        dt += curr_offset - old_offset
    return dt

print(resolve_imaginary(datetime(2004, 4, 4, 2, 30, tzinfo=NYC)))


2004-04-04 03:30:00-04:00

pytz

When using localize on an imaginary datetime, pytz will create an imaginary time and use is_dst to decide what offset to assign it:


In [55]:
print(NYC_pytz.localize(datetime(2004, 4, 4, 2, 30), is_dst=True))
print(NYC_pytz.localize(datetime(2004, 4, 4, 2, 30), is_dst=False))


2004-04-04 02:30:00-04:00
2004-04-04 02:30:00-05:00

If you have a non-existent date, normalize() will slide it forward or backwards, depending on the value passed to is_dst (default is False):


In [56]:
dt_imag_dst = NYC_pytz.localize(datetime(2004, 4, 4, 2, 30), is_dst=True)
dt_imag_std = NYC_pytz.localize(datetime(2004, 4, 4, 2, 30), is_dst=False)

print(NYC_pytz.normalize(dt_imag_dst))
print(NYC_pytz.normalize(dt_imag_std))


2004-04-04 01:30:00-05:00
2004-04-04 03:30:00-04:00

pytz

If you pass is_dst=None, pytz will throw a NonExistentTimeError:


In [57]:
dt_0 = datetime(2004, 4, 4, 1, 30)
for i in range(3):
    try:
        dt = NYC_pytz.localize(dt_0 + timedelta(hours=i), is_dst=None)
        exists = True
    except pytz.NonExistentTimeError:
        exists = False

    print('{} ({})'.format(dt, 'Exists' if exists else 'Imaginary'))


2004-04-04 01:30:00-05:00 (Exists)
2004-04-04 01:30:00-05:00 (Imaginary)
2004-04-04 03:30:00-04:00 (Exists)

pytz

dateutil.tz.datetime_exists() works with pytz zones, too


In [58]:
dt_pytz_real = NYC_pytz.localize(datetime(2004, 4, 4, 1, 30))
dt_pytz_imag = NYC_pytz.localize(datetime(2004, 4, 4, 2, 30))

print('Real:      {}'.format(tz.datetime_exists(dt_pytz_real)))
print('Imaginary: {}'.format(tz.datetime_exists(dt_pytz_imag)))


Real:      True
Imaginary: False

And will detect non-normalized datetimes:


In [59]:
dt_nn = dt_pytz_real + timedelta(hours=3)   # Needs to be normalized to DST
print('{}: {}'.format(dt_nn, 'Exists' if tz.datetime_exists(dt_nn) else 'Imaginary'))


2004-04-04 04:30:00-05:00: Imaginary

dateutil's tzinfo implementations

UTC and Static time zones


In [60]:
# tz.tzutc() is equivalent to pytz.UTC or timezone.utc
dt = datetime(2014, 12, 19, 22, 30, tzinfo=tz.tzutc())
print_tzinfo(dt)


2014-12-19 22:30:00+0000
    tzname:   UTC;      UTC Offset:   0.00h;        DST:      0.0h

Static offsets represent zones with a fixed offset from UTC, and takes a tzname or either number of seconds or a timedelta:


In [61]:
JST = tzoffset('JST', 32400)                       # Japan Standard Time is year round
IST = tzoffset('IST',                              # India Standard Time is year round
               timedelta(hours=5, minutes=30))  
EST = tzoffset(None, timedelta(hours=-5))          # Can use None as a name

dt = datetime(2016, 7, 17, 12, 15, tzinfo=tzutc())
print_tzinfo(dt.astimezone(JST))
print_tzinfo(dt.astimezone(IST))
print_tzinfo(dt.astimezone(EST))


2016-07-17 21:15:00+0900
    tzname:   JST;      UTC Offset:   9.00h;        DST:      0.0h
2016-07-17 17:45:00+0530
    tzname:   IST;      UTC Offset:   5.50h;        DST:      0.0h
2016-07-17 07:15:00-0500
    tzname:  None;      UTC Offset:  -5.00h;        DST:      0.0h

UTC and Static time zones

In Python 3.2, timezone objects were introduced to provide ready-made tzinfo subclasses for the simple case of static offsets from UTC.


In [62]:
from datetime import timezone
dt = datetime(2014, 12, 19, 22, 30, tzinfo=timezone.utc)     # Equivalent to pytz.UTC or dateutil.tz.tzutc()
print_tzinfo(dt)


2014-12-19 22:30:00+0000
    tzname:   UTC;      UTC Offset:   0.00h;        DST:      None

In [63]:
JST = timezone(timedelta(hours=9), 'JST')          # Japan Standard Time is year round
IST = timezone(timedelta(hours=5, minutes=30),     # India Standard Time is year round
               'IST')  
EST = timezone(timedelta(hours=-5))                # Without a name, it's UTC-hh:mm

dt = datetime(2016, 7, 17, 12, 15, tzinfo=tzutc())
print_tzinfo(dt.astimezone(JST)); print()
print_tzinfo(dt.astimezone(IST)); print()
print_tzinfo(dt.astimezone(EST))


2016-07-17 21:15:00+0900
    tzname:   JST;      UTC Offset:   9.00h;        DST:      None

2016-07-17 17:45:00+0530
    tzname:   IST;      UTC Offset:   5.50h;        DST:      None

2016-07-17 07:15:00-0500
    tzname: UTC-05:00;  UTC Offset:  -5.00h;        DST:      None

Local time

The tz.tzlocal() class is a tzinfo implementation that uses the OS hooks in Python's time module to get the local system time.


In [64]:
# Temporarily changes the TZ file on *nix systems.
from helper_functions import TZEnvContext

print_tzinfo(dt.astimezone(tz.tzlocal()))


2016-07-17 08:15:00-0400
    tzname:   EDT;      UTC Offset:  -4.00h;        DST:      1.0h

In [65]:
with TZEnvContext('UTC'):
    print_tzinfo(dt.astimezone(tz.tzlocal()))


2016-07-17 12:15:00+0000
    tzname:   UTC;      UTC Offset:   0.00h;        DST:      0.0h

In [66]:
with TZEnvContext('PST8PDT'):
    print_tzinfo((dt + timedelta(days=180)).astimezone(tz.tzlocal()))


2017-01-13 04:15:00-0800
    tzname:   PST;      UTC Offset:  -8.00h;        DST:      0.0h

Local time: Windows

tz.win.tzwinlocal() directly queries the Windows registry for its time zone data and uses that to construct a tzinfo.

Fixes this bug:

>>> dt = datetime(2014, 2, 11, 17, 0)

>>> print(dt.replace(tzinfo=tz.tzlocal()).tzname())
Eastern Standard Time

>>> print(dt.replace(tzinfo=tz.win.tzwinlocal()).tzname())
Eastern Standard Time

>>> with TZWinContext('Pacific Standard Time'):
...     print(dt.replace(tzinfo=tz.tzlocal()).tzname())
...     print(dt.replace(tzinfo=tz.win.tzwinlocal()).tzname())

Eastern Standard Time
Pacific Standard Time

IANA (Olson) database

The dateutil.tz.tzfile class provides support for IANA zoneinfo binaries (shipped with *nix systems).

DO NOT USE tz.tzfile directly - use tz.gettz()


In [67]:
NYC = tz.gettz('America/New_York')
NYC


Out[67]:
tzfile('/usr/share/zoneinfo/America/New_York')

The IANA database contains historical time zone transitions:


In [68]:
print_tzinfo(datetime(2017, 8, 12, 14, tzinfo=NYC))      # Eastern Daylight Time


2017-08-12 14:00:00-0400
    tzname:   EDT;      UTC Offset:  -4.00h;        DST:      1.0h

In [69]:
print_tzinfo(datetime(1944, 1, 6, 12, 15, tzinfo=NYC))    # Eastern War Time


1944-01-06 12:15:00-0400
    tzname:   EWT;      UTC Offset:  -4.00h;        DST:      1.0h

In [70]:
print_tzinfo(datetime(1901, 9, 6, 16, 7, tzinfo=NYC))     # Local solar mean


1901-09-06 16:07:00-0456
    tzname:   LMT;      UTC Offset:  -4.93h;        DST:      0.0h

tz.gettz()

The most general way to get a time zone is to pass the relevant timezone string to the gettz() function, which will try parsing it a number of different ways until it finds a relevant string.


In [71]:
tz.gettz()      # Passing nothing gives you local time


Out[71]:
tzfile('/etc/localtime')

In [72]:
# If your TZSTR is an an Olson file, it is prioritized over the /etc/localtime tzfile.
with TZEnvContext('CST6CDT'):
    print(gettz())


tzfile('/usr/share/zoneinfo/CST6CDT')

In [73]:
# If it doesn't find a tzfile, but it finds a valid abbreviation for the local zone,
# it returns tzlocal()
with TZEnvContext('LMT4'):
    print(gettz('LMT'))


tzlocal()

In [74]:
# Retrieve IANA zone:
print(gettz('Pacific/Kiritimati'))


tzfile('/usr/share/zoneinfo/Pacific/Kiritimati')

In [75]:
# Directly parse a TZ variable:
print(gettz('AEST-10AEDT-11,M10.1.0/2,M4.1.0/3'))


tzstr('AEST-10AEDT-11,M10.1.0/2,M4.1.0/3')

Final remarks

Using UTC

  • For events in the past (e.g. logging), store in UTC, convert to local before a user sees it.
  • For events in the future (e.g. appointments), store at least the civil time.

Naive datetimes

  • Naive datetimes represent a civil time unmoored from an absolute time.
  • Sometimes they are what you need.
  • Often it is easiest to leave your datetimes naive until some property of the timezone is needed.
  • 100+ open Python jobs
  • Offices in New York, London, San Francisco

Notable SF Jobs:

Plug

  1. If you're interested in helping out with dateutil development, check out the issues on the github or e-mail me.
  2. If you want to cross-sign PGP keys with me, please come talk to me and I will show you my ID.
Github / Twitter: @pganssle

GPG Key

6B49 ACBA DCF6 BD1C A206
67AB CD54 FCE3 D964 BEFB