Migration Guide

This guide explains how to migrate from using pytz to a PEP 495-compatible library, either the standard library module zoneinfo (or its backport, backports.zoneinfo), or python-dateutil.

Which replacement to choose?

If you only need to support Python 3, you should use zoneinfo. If you need to support Python 2 and Python 3, you should either use dateutil.tz or a combination of zoneinfo and dateutil. The shim classes use dateutil in Python 2 and zoneinfo in Python 3.

You can also directly use the shim classes if desired; the shim classes are, themselves, PEP 495-compatible time zone implementations. As long as you do not use any part of the pytz-specific interface, they will not raise any warnings. There is, however, some performance overhead associated with this as compared to using the underlying libraries directly.

If your time zones are coming from a source that returns pytz_deprecation_shim shim time zones, you can upgrade them to the PEP 495-compatible zone that they are a wrapper around using pytz_deprecation_shim.helpers.upgrade_tzinfo().

For more information about how to get time zones from the replacement you’ve chosen, see the section on acquiring a tzinfo object.

Creating an aware datetime (“localizing” datetimes)

With PEP 495-compatible zones, there is no longer any need to use localize, you can directly attach a time zone to your datetime:

import zoneinfo
from datetime import datetime

NYC = zoneinfo.ZoneInfo("America/New_York")
datetime(2020, 1, 1, tzinfo=NYC)

If you have a naïve datetime (or one you’d like to “reinterpret” an aware datetime as being in another zone), use replace():

datetime(2020, 1, 1).replace(tzinfo=NYC)

Ambiguous and imaginary times

Whenever a time zone’s UTC offset changes (such as during a Daylight Saving Time / Summer Time transition), this creates either a gap (“imaginary” times) of times that have been skipped over in local time or a fold (ambiguous times), where there are two possible local times with the same “wall time”. This is the problem that PEP 495 was created to address.

When using a PEP 495-compatible time zone, use the fold attribute to select the behavior you expect during these times. fold=0 (the default) corresponds to the offset that applied before the transition, while fold=1 corresponds to the offset that applies after the transition:

>>> dt = datetime(2020, 11, 1, 1, tzinfo=ZoneInfo("America/Los_Angeles"))
>>> print(dt)
2020-11-01 01:00:00-07:00
>>> dt.tzname()

>>> dt_enfolded = dt.replace(fold=1)
>>> print(dt_enfolded)
2020-11-01 01:00:00-08:00
>>> dt_enfolded.tzname()

Since PEP 495 was introduced in Python 3.6, the fold attribute is not available in earlier versions of Python. However, dateutil provides a backport for this feature via dateutil.tz.enfold(). If you are still supporting Python 2, you can use tz.enfold:

>>> dt = datetime(2020, 11, 1, 1, tzinfo=ZoneInfo("America/Los_Angeles"))
>>> print(dt)
2020-11-01 01:00:00-07:00
>>> dt.tzname()

>>> from dateutil import tz
>>> dt = datetime(2020, 11, 1, 1, tzinfo=tz.gettz("America/Los_Angeles"))
>>> dt.tzname()

>>> dt_enfolded = tz.enfold(dt)
>>> dt_enfolded.tzname()

The tz.enfold function is also compatible with the zoneinfo module, and can be used unconditionally in 2/3 compatible code that uses different time zone providers in Python 2 and 3.

Semantic differences between is_dst and fold

As mentioned in the previous section, during a fold or a gap, the offset information that applies is ill-defined. With pytz you disambiguate between these choices by using is_dst to select which side of the transition you want to interpret your naïve datetime as. With PEP 495, you choose that by setting the fold attribute of the datetime. Unfortunately, is_dst and fold do not cleanly map onto one another, because is_dst intends to choose whether to interpret the time as “daylight saving time” vs “standard time”, whereas fold selects between “the first offset” and “the second offset”. PEP 495 made the choice to avoid any explicit reference to DST because not all folds and gaps are created by DST-related transitions.

To demonstrate the difference, consider the timeline of a year with a standard time (STD) → daylight saving time (DST) transition in spring and its inverse in fall:


During a fold or a gap, .utcoffset(), .dst() and tzname() for a given datetime are ill-defined, and so a disambiguation method like is_dst or fold needs to be introduced. With pytz’s is_dst, the user is selecting whether to choose the DST or the STD offset when more than one answer is possible. The offsets that apply at each time during the year are illustrated below, with “returns DST offsets” shown in red, and “returns STD offsets” shown in blue:


With PEP 495’s fold, however, the user selects between whether to apply the offset from before the transition (fold = 0) or after the transition (fold = 1), as illustrated below:


These two don’t map onto one another perfectly. Most people likely care about the behavior during folds rather than gaps, because each ambiguous time during a fold represents a real time that occurred, whereas during gaps the primary ambiguity is due to the fact that in a sense both offsets are equally wrong, since no such time occurred. During the folds, the is_dst behavior can be approximated by setting fold = not is_dst, which will be valid except in in cases of negative daylight saving time (Winter time), such as occurs in Europe/Dublin zone (the behavior of is_dst during offset shifts unrelated to daylight saving time doesn’t seem like it would be well-defined, but a spot check of 1969-09-30T12 in the Pacific/Kwajalein zone indicates that fold = not is_dst has the same behavior).

Detecting ambiguous and imaginary times

pytz provides the option to raise an exception if the user attempts to localize a datetime that falls during a gap or a fold. Since zoneinfo and dateutil.tz don’t have an explicit localization step, there is no analogous option to throw an error, but it can be re-created using the dateutil functions dateutil.tz.datetime_ambiguous() and dateutil.tz.datetime_exists(), which work independent of the time zone provider in both Python 2 and 3.

So, if your pytz code looks like the following:

import pytz

    zone.localize(dt, is_dst=None)
except pytz.NonExistentTimeError:
except pytz.AmbiguousTimeError:

You can replace it with the following:

from dateutil import tz

dt = dt.replace(tzinfo=zone)  # Only needed if `dt` is naive

if not tz.datetime_exists(dt):
elif tz.datetime_ambiguous(dt):

If you are using zoneinfo and do not want to take on a dateutil dependency for this purpose, these functions can be approximated easily enough:

from datetime import timezone
def datetime_exists(dt):
    """Check if a datetime exists."""
    # There are no non-existent times in UTC, and comparisons between
    # aware time zones always compare absolute times; if a datetime is
    # not equal to the same datetime represented in UTC, it is imaginary.
    return dt.astimezone(timezone.utc) == dt

def datetime_ambiguous(dt):
    """Check whether a datetime is ambiguous."""
    # If a datetime exists and its UTC offset changes in response to
    # changing `fold`, it is ambiguous in the zone specified.
    return datetime_exists(dt) and (
        dt.replace(fold=not dt.fold).utcoffset() != dt.utcoffset())

Handling datetime arithmetic (“normalizing” datetimes)

With pytz, after any arithmetical operation on an aware datetime, it needs to be “normalized”, in case the addition has resulted in a datetime with a different offset from the originally-localized datetime. This is not the case with PEP 495-compatible datetimes, and arithmetic that crosses a transition boundary will have the correct offset values. For example:

>>> from zoneinfo import ZoneInfo
>>> from datetime import datetime

>>> dt = datetime(1992, 3, 1, tzinfo=ZoneInfo("Europe/Minsk"))
>>> print(dt)
1992-03-01 00:00:00+02:00
>>> print(dt.utcoffset())
>>> dt.tzname()

>>> dt += timedelta(days=90)
>>> print(dt)
1992-05-30 00:00:00+03:00
>>> print(dt.utcoffset())
>>> dt.tzname()

However, because this is using standard datetime mechanisms, the semantics are slightly different (see Semantics of timezone-aware datetime arithmetic for a more in-depth article on the subject). With a pytz “add-and-normalize” workflow, all addition is “absolute time” arithmetic (i.e. as if it were performed in UTC), whereas standard datetime arithmetic is “wall time” arithmetic.

So, an example of addition across a DST boundary using pytz:

>>> NYC = pytz.timezone("America/New_York")
>>> dt1 = NYC.localize(datetime(2018, 3, 10, 13))
>>> print(dt1)
2018-03-10 13:00:00-05:00

>>> dt2 = dt1 + timedelta(days=1)
>>> print(dt2)  # Note the offset has not changed!
2018-03-11 13:00:00-05:00

>>> print(NYC.normalize(dt2)) # Note the offset and time both change
2018-03-11 14:00:00-04:00

With a PEP 495 workflow, the default is to use “wall time” arithmetic, so timedelta(days=1) will produce the same time of day on the following day, regardless of whether 24 hours will have elapsed in local time or not. So code similar to the operation above instead gives you:

>>> NYC = ZoneInfo("America/New_York")
>>> dt1 = datetime(2018, 3, 10, 13, tzinfo=NYC)
>>> print(dt1)
2018-03-10 13:00:00-05:00

>>> dt2 = dt1 + timedelta(days=1)
>>> print(dt2)
2018-03-11 13:00:00-04:00

It is worth noting that this “wall time” arithmetic may produce an imaginary or ambiguous time. To handle that situation, see Detecting ambiguous and imaginary times.

If you want “absolute time” rather than “wall time” arithmetic, the best option is to perform the arithmetic in UTC. Here is a simple helper function for that purpose (in Python 2 or 2/3 compatible code, replace datetime.timezone.utc with dateutil.tz.UTC):

from datetime import timezone
def absolute_add(dt, td):
    dt_utc = dt.astimezone(timezone.utc)
    return (dt_utc + td).astimezone(dt.tzinfo)

This will have the same semantics as “add and normalize” in pytz, and similarly guarantees that the result exists.

Getting a time zone’s name

pytz zones have a .zone attribute that exposes the key used to created it from the IANA time zone database. The equivalent attribute on zoneinfo.ZoneInfo objects is zoneinfo.ZoneInfo.key. There is currently no equivalent for this in dateutil zones.

You can also recover this information by calling str on a pytz zone, a shim class zone (even in Python 2), or a zoneinfo.ZoneInfo zone, e.g.:

>>> LA = zoneinfo.ZoneInfo("America/Los_Angeles")
>>> str(LA)

Acquiring a tzinfo object

Most of this guide assumes that you already have a time zone object, because it is aimed at people who were using pytz-specific features of a time zone returned by a library that is switching over to use a PEP 495-compatible time zone provider. However, if you are also creating your own pytz objects, or you want to switch to directly creating tzinfo objects yourself, this section covers creating PEP 495-compatible tzinfo objects.

IANA zones

With pytz, one creates an IANA / Olson time zone object via the pytz.timezone function, like so:

import pytz
LA = pytz.timezone("America/Los_Angeles")
# <DstTzInfo 'America/Los_Angeles' LMT-1 day, 16:07:00 STD>

When using zoneinfo, instead use the zoneinfo.ZoneInfo constructor. Note: in Python 3.6-3.8, replace import zoneinfo with from backports import zoneinfo:

import zoneinfo
LA = zoneinfo.ZoneInfo("America/Los_Angeles")
# zoneinfo.ZoneInfo(key='America/Los_Angeles')

When using dateutil.tz, use dateutil.tz.gettz():

from dateutil import tz
LA = tz.gettz("America/Los_Angeles")
# tzfile('/usr/share/zoneinfo/America/Los_Angeles')

UTC and fixed offset zones

pytz provides a convenience singleton pytz.UTC, as well as a FixedOffset function, for constructing a value with a fixed offset in minutes.

To get an object representing UTC, in Python 3+, use the standard library-provided datetime.timezone.utc singleton. When using dateutil, use dateutil.tz.UTC.

To construct a fixed offset zone, use datetime.timezone in Python 3 and dateutil.tz.tzoffset in Python 2.