ZetCode

Python zoneinfo

last modified October 29, 2026 Python 3.15

In this article we show how to work with time zones in Python using the zoneinfo module.

The zoneinfo module was added to the standard library in Python 3.9 (PEP 615). It provides the ZoneInfo class, a concrete implementation of the datetime.tzinfo abstract class backed by the IANA time zone database. Before zoneinfo, the third-party pytz library was the usual choice; zoneinfo is simpler to use, because it works with the standard datetime API and needs no localize or normalize calls.

The IANA time zone database (also called tz or zoneinfo database) records the history of UTC offsets and daylight saving time rules for locations around the world. Zones are identified by keys such as Europe/Bratislava or America/New_York. On Linux and macOS, Python reads the database from the system, usually from /usr/share/zoneinfo. On Windows, which has no system copy, the first-party tzdata package from PyPI provides the data.

The basics of dates and times are covered in the Python datetime article.

A first time zone

A ZoneInfo object is created from a zone key and passed as the tzinfo argument of a datetime.

first_zone.py
from datetime import datetime
from zoneinfo import ZoneInfo

tz = ZoneInfo('Europe/Bratislava')
dt = datetime(2026, 7, 15, 14, 30, tzinfo=tz)

print(dt)
print(dt.tzname())
print(dt.utcoffset())
print(dt.dst())
print(tz.key)

We create an aware datetime, one that knows its time zone, for 2:30 PM in Bratislava on a summer day.

dt = datetime(2026, 7, 15, 14, 30, tzinfo=tz)

The zone does not store a single offset. When we ask for it, the zone looks up the rules in force on that date: in July, Central Europe uses summer time.

print(dt.tzname())
print(dt.utcoffset())
print(dt.dst())

The tzname method returns the abbreviation, utcoffset the total offset from UTC, and dst the part of the offset caused by daylight saving time. The key attribute holds the name the zone was created with.

$ python first_zone.py
2026-07-15 14:30:00+02:00
CEST
2:00:00
1:00:00
Europe/Bratislava

World clock

The astimezone method converts an aware datetime to another time zone. The instant stays the same; only the local representation changes.

world_clock.py
from datetime import datetime, UTC
from zoneinfo import ZoneInfo

cities = {
    'Bratislava': 'Europe/Bratislava',
    'London': 'Europe/London',
    'New York': 'America/New_York',
    'Tokyo': 'Asia/Tokyo',
    'Kolkata': 'Asia/Kolkata',
    'Kathmandu': 'Asia/Kathmandu',
    'Sydney': 'Australia/Sydney',
}

now = datetime.now(UTC)
print(f'UTC: {now:%Y-%m-%d %H:%M}')

for city, key in cities.items():
    local = now.astimezone(ZoneInfo(key))
    print(f'{city:<12} {local:%Y-%m-%d %H:%M} {local.tzname():<6} {local:%z}')

We take the current time in UTC and show it in several cities. Kolkata and Kathmandu show that offsets are not always whole hours.

now = datetime.now(UTC)

Passing a time zone to datetime.now returns an aware datetime. The datetime.UTC constant, available since Python 3.11, is an alias for timezone.utc.

print(f'{city:<12} {local:%Y-%m-%d %H:%M} {local.tzname():<6} {local:%z}')

The %z directive formats the UTC offset. For Kathmandu, the database has no abbreviation, so tzname returns the numeric offset.

$ python world_clock.py
UTC: 2026-10-29 21:41
Bratislava   2026-10-29 22:41 CET    +0100
London       2026-10-29 21:41 GMT    +0000
New York     2026-10-29 17:41 EDT    -0400
Tokyo        2026-10-30 06:41 JST    +0900
Kolkata      2026-10-30 03:11 IST    +0530
Kathmandu    2026-10-30 03:26 +0545  +0545
Sydney       2026-10-30 08:41 AEDT   +1100

Scheduling a meeting

A typical task is to tell participants in other countries when a meeting takes place.

meeting.py
from datetime import datetime
from zoneinfo import ZoneInfo

meeting = datetime(2026, 11, 12, 16, 0, tzinfo=ZoneInfo('Europe/Berlin'))

for key in ['America/Los_Angeles', 'America/Sao_Paulo', 'Asia/Singapore',
            'Pacific/Auckland']:
    local = meeting.astimezone(ZoneInfo(key))
    print(f'{key:<20} {local:%a %d %b %H:%M}')

The meeting is scheduled for 4 PM Berlin time. We convert it to the local times of four participants. In Auckland, the meeting falls on the next day.

$ python meeting.py
America/Los_Angeles  Thu 12 Nov 07:00
America/Sao_Paulo    Thu 12 Nov 12:00
Asia/Singapore       Thu 12 Nov 23:00
Pacific/Auckland     Fri 13 Nov 04:00

Naive and aware datetimes

A datetime without tzinfo is naive. The replace method attaches a zone to it without changing the wall time, while astimezone converts the time.

naive_aware.py
from datetime import datetime
from zoneinfo import ZoneInfo

naive = datetime(2026, 7, 15, 9, 0)
print(naive, naive.tzinfo)

attached = naive.replace(tzinfo=ZoneInfo('America/Chicago'))
print(attached)

converted = attached.astimezone(ZoneInfo('Asia/Tokyo'))
print(converted)

print(attached == converted)

try:
    print(naive < attached)
except TypeError as e:
    print('TypeError:', e)

We attach the Chicago zone to a naive 9 AM and convert the result to Tokyo time.

attached = naive.replace(tzinfo=ZoneInfo('America/Chicago'))

Using replace is correct with ZoneInfo. With pytz, this was a common bug, because the pytz zones had to be attached with localize.

print(attached == converted)

Aware datetimes are compared as instants, so 9 AM in Chicago equals 11 PM in Tokyo. Naive and aware datetimes cannot be ordered; Python raises a TypeError.

$ python naive_aware.py
2026-07-15 09:00:00 None
2026-07-15 09:00:00-05:00
2026-07-15 23:00:00+09:00
True
TypeError: can't compare offset-naive and offset-aware datetimes

Daylight saving time

The offset of a zone changes during the year when it observes daylight saving time.

dst_offsets.py
from datetime import datetime
from zoneinfo import ZoneInfo

tz = ZoneInfo('Europe/Bratislava')

for month in (1, 3, 4, 7, 10, 11):
    dt = datetime(2026, month, 1, 12, 0, tzinfo=tz)
    print(f'{dt:%b}  {dt.tzname()}  offset {dt.utcoffset()}  dst {dt.dst()}')

We check the offset of the Bratislava zone at noon on the first day of several months. Summer time (CEST) is in force from the end of March to the end of October.

$ python dst_offsets.py
Jan  CET  offset 1:00:00  dst 0:00:00
Mar  CET  offset 1:00:00  dst 0:00:00
Apr  CEST  offset 2:00:00  dst 1:00:00
Jul  CEST  offset 2:00:00  dst 1:00:00
Oct  CEST  offset 2:00:00  dst 1:00:00
Nov  CET  offset 1:00:00  dst 0:00:00

Finding transitions

The ZoneInfo class has no public method that lists transitions. We can find them by stepping through the year in UTC and watching the offset.

transitions.py
from datetime import datetime, timedelta, UTC
from zoneinfo import ZoneInfo

def transitions(key, year):
    tz = ZoneInfo(key)
    t = datetime(year, 1, 1, tzinfo=UTC)
    end = datetime(year + 1, 1, 1, tzinfo=UTC)
    prev = t.astimezone(tz).utcoffset()

    while t < end:
        t += timedelta(hours=1)
        offset = t.astimezone(tz).utcoffset()
        if offset != prev:
            before = (t - timedelta(hours=1)).astimezone(tz)
            after = t.astimezone(tz)
            print(f'{key}: {before:%Y-%m-%d %H:%M %Z} -> {after:%H:%M %Z}')
            prev = offset

for key in ['Europe/Bratislava', 'America/New_York', 'Australia/Sydney',
            'Asia/Tokyo']:
    transitions(key, 2026)

We step through the year hour by hour. Stepping in UTC, not in local time, guarantees that each step is exactly one hour long.

before = (t - timedelta(hours=1)).astimezone(tz)
after = t.astimezone(tz)

When the offset changes, we print the local time just before and just after the change. In spring, clocks jump from 2:00 to 3:00, and in autumn they fall back from 3:00 to 2:00. Sydney is in the southern hemisphere, so its summer time starts in October. Tokyo does not observe daylight saving time and prints nothing.

$ python transitions.py
Europe/Bratislava: 2026-03-29 01:00 CET -> 03:00 CEST
Europe/Bratislava: 2026-10-25 02:00 CEST -> 02:00 CET
America/New_York: 2026-03-08 01:00 EST -> 03:00 EDT
America/New_York: 2026-11-01 01:00 EDT -> 01:00 EST
Australia/Sydney: 2026-04-05 02:00 AEDT -> 02:00 AEST
Australia/Sydney: 2026-10-04 01:00 AEST -> 03:00 AEDT

Ambiguous times and fold

When clocks fall back, an hour of local time repeats. In Bratislava, 2:30 AM on 25 October 2026 happens twice: first in CEST, then in CET. The fold attribute of a datetime (PEP 495) selects one of them.

fold_ambiguous.py
from datetime import datetime, UTC
from zoneinfo import ZoneInfo

tz = ZoneInfo('Europe/Bratislava')

first = datetime(2026, 10, 25, 2, 30, tzinfo=tz)
second = first.replace(fold=1)

for dt in (first, second):
    print(dt, dt.tzname(), dt.fold, dt.astimezone(UTC))

print(first == second)
print(second - first)
print(second.astimezone(UTC) - first.astimezone(UTC))

The default fold=0 selects the first occurrence and fold=1 the second one. Converted to UTC, they are one hour apart.

print(first == second)
print(second - first)

These results are surprising. Datetimes that share the same tzinfo object are compared and subtracted by their wall time, ignoring fold. To get the real elapsed time, convert both values to UTC first.

$ python fold_ambiguous.py
2026-10-25 02:30:00+02:00 CEST 0 2026-10-25 00:30:00+00:00
2026-10-25 02:30:00+01:00 CET 1 2026-10-25 01:30:00+00:00
True
0:00:00
1:00:00

Missing times

When clocks spring forward, an hour of local time is skipped. In Bratislava, there is no 2:30 AM on 29 March 2026. Python does not raise an error for such times.

gap.py
from datetime import datetime, UTC
from zoneinfo import ZoneInfo

tz = ZoneInfo('Europe/Bratislava')

def exists(dt):
    roundtrip = dt.astimezone(UTC).astimezone(dt.tzinfo)
    return roundtrip.replace(tzinfo=None) == dt.replace(tzinfo=None)

for hour, minute in [(1, 30), (2, 30), (3, 30)]:
    dt = datetime(2026, 3, 29, hour, minute, tzinfo=tz)
    print(dt, exists(dt), dt.astimezone(UTC).astimezone(tz))

A missing time does not survive a round trip through UTC: 2:30 AM is interpreted with the offset in force before the transition and comes back as 3:30 AM CEST. We use this to detect it.

def exists(dt):
    roundtrip = dt.astimezone(UTC).astimezone(dt.tzinfo)
    return roundtrip.replace(tzinfo=None) == dt.replace(tzinfo=None)

The function compares the wall times before and after the round trip. Applications that accept local times from users, such as calendars, should check for missing times and ask the user or move the time forward.

$ python gap.py
2026-03-29 01:30:00+01:00 True 2026-03-29 01:30:00+01:00
2026-03-29 02:30:00+01:00 False 2026-03-29 03:30:00+02:00
2026-03-29 03:30:00+02:00 True 2026-03-29 03:30:00+02:00

Arithmetic across transitions

Adding a timedelta to an aware datetime is wall-time arithmetic: the result keeps the zone and adds the duration to the local clock reading.

arithmetic.py
from datetime import datetime, timedelta, UTC
from zoneinfo import ZoneInfo

tz = ZoneInfo('America/New_York')

start = datetime(2026, 11, 1, 0, 0, tzinfo=tz)
later = start + timedelta(hours=24)

print(start)
print(later)
print(later - start)

real = later.astimezone(UTC) - start.astimezone(UTC)
print(real)

exact = (start.astimezone(UTC) + timedelta(hours=24)).astimezone(tz)
print(exact)

On 1 November 2026, New York switches from EDT to EST, so that day has 25 hours.

later = start + timedelta(hours=24)

Adding 24 hours to midnight gives midnight of the next day, which is 25 real hours later. Subtracting the two values in the same zone gives one day, while subtracting them in UTC gives the real elapsed time.

exact = (start.astimezone(UTC) + timedelta(hours=24)).astimezone(tz)

To add an exact duration, we do the arithmetic in UTC and convert back. Use wall-time arithmetic for things like "the same time tomorrow" and UTC arithmetic for elapsed time, such as timeouts.

$ python arithmetic.py
2026-11-01 00:00:00-04:00
2026-11-02 00:00:00-05:00
1 day, 0:00:00
1 day, 1:00:00
2026-11-01 23:00:00-05:00

Timestamps and ISO strings

Unix timestamps and ISO 8601 strings are common ways to exchange times.

timestamps.py
from datetime import datetime
from zoneinfo import ZoneInfo

ts = 1_800_000_000

for key in ['UTC', 'Europe/Bratislava', 'America/Denver']:
    dt = datetime.fromtimestamp(ts, tz=ZoneInfo(key))
    print(f'{key:<18} {dt.isoformat()}')

parsed = datetime.fromisoformat('2027-01-15T08:00:00+01:00')
print(parsed.tzinfo)
print(parsed.astimezone(ZoneInfo('Asia/Tokyo')))

The fromtimestamp method with a tz argument turns a Unix timestamp into an aware datetime in the given zone.

parsed = datetime.fromisoformat('2027-01-15T08:00:00+01:00')

An ISO string carries only an offset, not a zone, so the parsed datetime has a fixed-offset timezone. We cannot tell whether it came from Bratislava, Paris, or Lagos. If the zone matters, store its key next to the time.

$ python timestamps.py
UTC                2027-01-15T08:00:00+00:00
Europe/Bratislava  2027-01-15T09:00:00+01:00
America/Denver     2027-01-15T01:00:00-07:00
UTC+01:00
2027-01-15 16:00:00+09:00

UTC and fixed offsets

The datetime.timezone class represents fixed offsets. It is different from a ZoneInfo zone, which follows the rules of a place.

utc_zones.py
from datetime import datetime, timedelta, timezone, UTC
from zoneinfo import ZoneInfo

dt = datetime(2026, 7, 15, 12, 0)
zones = [UTC, timezone(timedelta(hours=2)), ZoneInfo('UTC'),
         ZoneInfo('Europe/Bratislava')]

for tz in zones:
    aware = dt.replace(tzinfo=tz)
    print(f'{type(tz).__name__:<9} {aware}  {aware.tzname()}')

january = datetime(2026, 1, 15, 12, 0)
print(january.replace(tzinfo=zones[1]).tzname(),
      january.replace(tzinfo=zones[3]).tzname())

For UTC, datetime.UTC and ZoneInfo('UTC') behave the same; datetime.UTC does not need the database. In summer, UTC+02:00 and Europe/Bratislava give the same offset, but in winter only the zone switches to CET.

$ python utc_zones.py
timezone  2026-07-15 12:00:00+00:00  UTC
timezone  2026-07-15 12:00:00+02:00  UTC+02:00
ZoneInfo  2026-07-15 12:00:00+00:00  UTC
ZoneInfo  2026-07-15 12:00:00+02:00  CEST
UTC+02:00 CET

Available time zones

The available_timezones function returns a set of all valid keys found in the database.

list_zones.py
from collections import Counter
from zoneinfo import available_timezones

zones = available_timezones()
print(len(zones))

regions = Counter(key.split('/')[0] for key in zones if '/' in key)
for region, count in regions.most_common(6):
    print(f'{region:<12} {count}')

slovak = sorted(key for key in zones if 'Brat' in key or 'Prague' in key)
print(slovak)

We count the zones, group them by region, and look for two keys. The function scans the whole database, so it is slow; call it once and keep the result. The numbers depend on the version of the database installed on the system.

$ python list_zones.py
598
America      169
Asia         99
Europe       64
Africa       54
Pacific      44
Etc          35
['Europe/Bratislava', 'Europe/Prague']

Invalid keys

The ZoneInfoNotFoundError exception, a subclass of KeyError, is raised when a key is not found.

not_found.py
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

for key in ['Europe/Kosice', 'europe/bratislava', '../etc/passwd', 'Europe/Prague']:
    try:
        tz = ZoneInfo(key)
        print(f'{key}: {tz}')
    except ZoneInfoNotFoundError as e:
        print(f'{key}: not found: {e}')
    except ValueError as e:
        print(f'{key}: invalid key: {e}')

Košice is in Slovakia, but the database names zones after one city per region, so there is no Europe/Kosice key. Keys are case sensitive on case-sensitive file systems. Keys that try to escape the database directory raise a ValueError.

$ python not_found.py
Europe/Kosice: not found: 'No time zone found with key Europe/Kosice'
europe/bratislava: not found: 'No time zone found with key europe/bratislava'
../etc/passwd: invalid key: ZoneInfo keys must refer to subdirectories of TZPATH, got: ../etc/passwd
Europe/Prague: Europe/Prague

The cache

ZoneInfo objects are cached: calling the constructor with the same key returns the same object.

zone_cache.py
from zoneinfo import ZoneInfo

a = ZoneInfo('Asia/Tokyo')
b = ZoneInfo('Asia/Tokyo')
c = ZoneInfo.no_cache('Asia/Tokyo')

print(a is b)
print(a is c)
print(a == c)

ZoneInfo.clear_cache()
d = ZoneInfo('Asia/Tokyo')
print(a is d)

The cache makes zone creation cheap and is required for correct comparisons: datetimes in the same zone are compared by wall time only if they share the same tzinfo object.

c = ZoneInfo.no_cache('Asia/Tokyo')

The no_cache constructor bypasses the cache and reads the data again. Zones are compared by identity, so two objects for the same key are not equal. The clear_cache method empties the cache; it is useful when the system database has been updated while the program runs.

$ python zone_cache.py
True
False
False
False

The time zone database

The TZPATH tuple lists the directories searched for zone files.

zone_source.py
import zoneinfo
from pathlib import Path

print(zoneinfo.TZPATH)

path = next(Path(p) / 'Europe/Vienna' for p in zoneinfo.TZPATH
            if (Path(p) / 'Europe/Vienna').exists())
print(path)

with path.open('rb') as f:
    print(f.read(4))
    f.seek(0)
    tz = zoneinfo.ZoneInfo.from_file(f, key='Vienna (from file)')

print(tz)
print(tz == zoneinfo.ZoneInfo('Europe/Vienna'))

We find the file for Vienna and load it with from_file. The files are in the binary TZif format and start with the TZif magic bytes.

tz = zoneinfo.ZoneInfo.from_file(f, key='Vienna (from file)')

A zone created from a file is not cached and its key is only a label. If no system database is found, Python falls back to the tzdata package. The search path can be changed with the PYTHONTZPATH environment variable or the reset_tzpath function.

$ python zone_source.py
('/usr/share/zoneinfo', '/usr/lib/zoneinfo', '/usr/share/lib/zoneinfo', '/etc/zoneinfo')
/usr/share/zoneinfo/Europe/Vienna
b'TZif'
Vienna (from file)
False

Pickling

Datetimes with ZoneInfo zones can be pickled.

pickling.py
import pickle
from datetime import datetime
from zoneinfo import ZoneInfo

dt = datetime(2026, 12, 24, 18, 0, tzinfo=ZoneInfo('Europe/Bratislava'))
data = pickle.dumps(dt)

print(len(data))
print(b'Europe/Bratislava' in data)

restored = pickle.loads(data)
print(restored)
print(restored.tzinfo is dt.tzinfo)

A zone is pickled by its key, not by its data, so the pickle is small. When loaded, the zone is created again from the key and the cache returns the same object.

$ python pickling.py
142
True
2026-12-24 18:00:00+01:00
True

Source

zoneinfo – IANA time zone support

PEP 615 – Support for the IANA Time Zone Database in the Standard Library

In this article we have worked with time zones using the Python zoneinfo module.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Python tutorials.