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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
List all Python tutorials.