Time Zones

Date and Time in Practice

Chapter 4 ยท Time Zones

Chapter 3 used fixed offsets such as +05:30. A fixed offset is the right tool when the offset really is fixed, but most places change theirs during the year. This chapter introduces real time zones through the standard-library zoneinfo module, which looks zones up by name in the IANA time zone database. The examples use Python 3.14, and every snippet was run; the output shown in the comments is what Python printed.

A Zone Is a Set of Rules

A ZoneInfo object is named by an IANA key of the form Region/City, such as Europe/London or Asia/Tokyo. It does not hold one offset; it holds the history and rules for a place, and answers with the offset that applies to the date you give it.

from datetime import datetime, timezone, timedelta from zoneinfo import ZoneInfo, ZoneInfoNotFoundError london = ZoneInfo("Europe/London") summer = datetime(2026, 7, 1, 9, 0, tzinfo=london) winter = datetime(2026, 1, 15, 9, 0, tzinfo=london) print(summer, summer.tzname(), summer.utcoffset()) # 2026-07-01 09:00:00+01:00 BST 1:00:00 print(winter, winter.tzname(), winter.utcoffset()) # 2026-01-15 09:00:00+00:00 GMT 0:00:00 print(summer.tzinfo.key) # Europe/London

The same zone gave +01:00 in July and +00:00 in January. A fixed timezone object could never do that. The abbreviation (BST, GMT) is also a result, not an input: tzname() reports it, but you never construct a zone from one.

timezone(timedelta(...))ZoneInfo("Region/City")
Offsetone value, alwaysdepends on the date
Knows seasonal changes?noyes
Source of datayouthe IANA database
Best foran offset that was recorded as a numbera place that people live in

Converting Between Zones

Conversion is the astimezone you met in Chapter 3. The moment stays the same; only the reading and label change.

tokyo = ZoneInfo("Asia/Tokyo") ny = ZoneInfo("America/New_York") print(summer.astimezone(tokyo)) # 2026-07-01 17:00:00+09:00 print(summer.astimezone(ny)) # 2026-07-01 04:00:00-04:00 print(summer.astimezone(timezone.utc)) # 2026-07-01 08:00:00+00:00 print(summer == summer.astimezone(ny)) # True

The last line is the point of the exercise: three different readings, one instant, and equality holds. The offsets are not always whole hours (India is +05:30, Nepal +05:45), which is another reason never to compute a conversion by adding hours yourself.

Attaching a zone to a reading
Writing datetime(..., tzinfo=ZoneInfo(...)) is correct when you know the reading was made in that zone. It is the same idea as replace(tzinfo=...) from Chapter 3: it labels, it does not convert. Chapter 5 returns to the readings where even this labelling is ambiguous.

When a Name Is Not Found

Keys must match the database exactly. Abbreviations and misspellings are rejected with ZoneInfoNotFoundError, so user-supplied zone names should always be validated:

for name in ["Europe/Londn", "BST"]: try: ZoneInfo(name) except ZoneInfoNotFoundError as e: print(type(e).__name__, e) # ZoneInfoNotFoundError 'No time zone found with key Europe/Londn' # ZoneInfoNotFoundError 'No time zone found with key BST'

There is a second way to get the same error, and it has nothing to do with spelling. zoneinfo reads the zone database from the operating system. Windows does not ship one, so on a Windows machine every lookup fails until you install the tzdata package, which is a pure data package from the Python Package Index:

py -m pip install tzdata

If a program works on one machine and raises ZoneInfoNotFoundError for a perfectly good key on another, suspect a missing database before you suspect the key. Listing available_timezones() (from zoneinfo) shows what the current machine can resolve.

Store UTC, Show Local

The usual design is to convert to UTC as soon as a moment enters the program, store and compare it in UTC, and convert to a viewer's zone only to display it. UTC has no seasonal changes, so UTC values never become ambiguous, and sorting a column of them is correct.

meeting = datetime(2026, 10, 20, 15, 0, tzinfo=ny) stored = meeting.astimezone(timezone.utc) print(stored.isoformat()) # 2026-10-20T19:00:00+00:00 for z in ["America/New_York", "Europe/London", "Asia/Tokyo", "Australia/Sydney"]: print(z, stored.astimezone(ZoneInfo(z)).strftime("%Y-%m-%d %H:%M %Z %z")) # America/New_York 2026-10-20 15:00 EDT -0400 # Europe/London 2026-10-20 20:00 BST +0100 # Asia/Tokyo 2026-10-21 04:00 JST +0900 # Australia/Sydney 2026-10-21 06:00 AEDT +1100

Notice that two of the four readings fall on the next calendar day. That is normal, and it is why a stored date without its zone is not enough information to show a user.

Why Not Just Store the Offset?

A fixed offset recorded today describes today. A zone name describes a place, and the place's rules decide the offset on any other day. Compare a fixed -5 with America/New_York on a July date:

fixed = timezone(timedelta(hours=-5)) jan = datetime(2026, 1, 15, 12, tzinfo=ny) jul = datetime(2026, 7, 15, 12, tzinfo=ny) print(jan.utcoffset().total_seconds() / 3600, jul.utcoffset().total_seconds() / 3600) # -5.0 -4.0 print(jan.astimezone(timezone.utc)) # 2026-01-15 17:00:00+00:00 print(jul.astimezone(timezone.utc)) # 2026-07-15 16:00:00+00:00 print(datetime(2026, 7, 15, 12, tzinfo=fixed).astimezone(timezone.utc)) # 2026-07-15 17:00:00+00:00

The fixed offset is wrong by an hour for the whole summer. If a user says "every Monday at 09:00 in New York", the thing to keep is the pair (09:00, America/New_York), not an offset.

Two aware values compare by instant, so zone choice does not affect equality when the instant is the same, and a shared clock reading in different zones is a different instant:

print(datetime(2026, 7, 1, 9, tzinfo=london) == datetime(2026, 7, 1, 8, tzinfo=timezone.utc)) # True print(datetime(2026, 7, 1, 9, tzinfo=london) == datetime(2026, 7, 1, 9, tzinfo=ZoneInfo("Europe/Paris"))) # False
A working rule
Moments in the past or the near-term future that must stay fixed: store UTC. Recurring plans tied to a place ("09:00 local, every Monday"): store the local time and the zone name, and compute the UTC moment for each occurrence when you need it.

Challenges

Exercise 1

Write world_clock(moment, names) that takes one aware UTC datetime and a list of zone names, and prints one line per zone with the local date and time, the abbreviation and the offset in hours. Use the moment 23:30 UTC on 20 November 2026 with Europe/London, Asia/Kolkata, Pacific/Auckland and America/Los_Angeles. Before you run it, predict which zones will show a different calendar date from UTC.

๐Ÿ“„ View solution
Exercise 2

For Europe/London, America/New_York, Australia/Sydney, Asia/Kolkata and Asia/Tokyo, print the UTC offset in hours at noon on 15 January 2026 and at noon on 15 July 2026, and label each zone changes or constant. Predict the labels first, and say which zone has the larger offset in January.

๐Ÿ“„ View solution
Exercise 3

Write get_zone(name) that returns a ZoneInfo, or None when the name is not found, and test it with Europe/Paris, BST, Europe/Londn and America/Chicago. Then take 18:00 on 3 September 2026 in Europe/Paris, convert it to UTC for storage, print the stored ISO text, show it in Europe/Paris, America/Chicago and Asia/Tokyo, and print whether the stored value equals the original.

๐Ÿ“„ View solution

Quick Reference

  • Zone: ZoneInfo("Region/City"), an IANA key; holds rules, not one offset
  • Convert: dt.astimezone(ZoneInfo(...)); same instant, new reading
  • Label a known local reading: datetime(..., tzinfo=ZoneInfo(...))
  • Bad key: ZoneInfoNotFoundError; abbreviations are not keys
  • Missing database (common on Windows): py -m pip install tzdata
  • Store UTC; convert to a viewer's zone only to display
  • Recurring local plans: keep the local time and the zone name, not an offset
Coming next
Date and Time in Practice 5 looks at what happens when the rules change the clock: readings that do not exist, readings that happen twice, and the difference between adding a day and adding twenty-four hours.