← All tutorials
Python

Python Timezones: A Practical Guide to zoneinfo, DST, and UTC

A fixed UTC offset only tells the truth for one day of the year. This guide builds a distributed-team meeting scenario, then works through zoneinfo, DST-aware conversions, Unix timestamps, and ISO 8601 parsing on a scenario you can reproduce yourself.

A datetime object only tells you when something happened once you know where the clock was read from — and “where” isn’t a fixed number, it’s a place with rules that change twice a year. Our guide to Python’s datetime basics covers building, parsing, and formatting dates, plus attaching a fixed UTC offset. That’s enough for logging or a single-office script. It falls apart the moment your program has to talk to people, or systems, in more than one place.

This is where a lot of otherwise-solid datetime code quietly breaks: a fixed offset that was correct in January is wrong by an hour in July, a raw Unix timestamp gets converted using whichever timezone the server happens to be running in, and “1:30 AM” can mean two different moments on one night a year. This guide covers the module built to handle exactly that — zoneinfo — through one small, reproducible scenario: scheduling a meeting across three cities.

The Mental Model: A Timezone Is a Rulebook, Not a Number

A fixed UTC offset, like +01:00, is a single number. A real timezone is a rulebook for a place: it says which offset applies on which dates, including when clocks jump forward or back for daylight saving time (DST). Keep three ideas in mind:

  1. datetime.timezone objects hold one fixed offset, forever. zoneinfo.ZoneInfo objects hold a named place ("Europe/Berlin") and look up the correct offset for that specific date.
  2. The same named zone can report a different UTC offset on two different days of the year — that’s DST, not a bug.
  3. Converting a point in time between zones with .astimezone() never changes when it happened, only which rulebook is used to display it.
A diagram shows one meeting instant, 14:00 UTC, converted into three local times for Berlin, New York, and Tokyo. Below it, a second row shows the same UTC instant on a July date: Berlin's local time shifts from 15:00 to 16:00 and New York's shifts from 09:00 to 10:00 because of daylight saving time, while Tokyo stays at 23:00 both times because Japan does not observe DST.

Once you think of a timezone as a lookup table keyed by date rather than a single number, everything below is just applying that lookup at the right moment.

A Meeting You Can Reproduce

Rivet Labs is a small, fully remote team. Every Monday they hold a stand-up at a fixed moment in UTC, and three teammates each see it at their own local time: Amara in Berlin, Diego in New York, and Priya in Tokyo.

from datetime import datetime
from zoneinfo import ZoneInfo

standup_utc = datetime(2026, 1, 12, 14, 0, tzinfo=ZoneInfo("UTC"))
print(standup_utc)
print(standup_utc.tzinfo)
2026-01-12 14:00:00+00:00
UTC

standup_utc is one unambiguous instant: 14:00 UTC on January 12th, 2026. Everything in this post starts from that single point and asks “what does this look like from here?”

Fixed Offset vs. ZoneInfo

Chapter one of the datetime basics guide built aware datetimes with timezone(timedelta(hours=2)) — a fixed offset. You can convert standup_utc to Berlin time that way, and on this particular January date it even gives the right answer:

from datetime import timedelta, timezone

berlin_fixed = standup_utc.astimezone(timezone(timedelta(hours=1)))
berlin_named = standup_utc.astimezone(ZoneInfo("Europe/Berlin"))
print(berlin_fixed)
print(berlin_named)
print(berlin_fixed == berlin_named)
2026-01-12 15:00:00+01:00
2026-01-12 15:00:00+01:00
True

Both give 15:00:00+01:00 today. The difference is that berlin_fixed will keep insisting on +01:00 for every date you ever hand it, while berlin_named will look up the correct offset for whatever date it’s given — including the day Germany’s clocks move forward. We’ll see that split in the next section.

Converting the Meeting Across the Team

.astimezone() is the one method that does the actual “what does this look like from here” conversion. Build one ZoneInfo per teammate’s city and convert the same instant three ways:

amara_berlin = standup_utc.astimezone(ZoneInfo("Europe/Berlin"))
diego_nyc = standup_utc.astimezone(ZoneInfo("America/New_York"))
priya_tokyo = standup_utc.astimezone(ZoneInfo("Asia/Tokyo"))

for name, dt in [
    ("Amara (Berlin)", amara_berlin),
    ("Diego (New York)", diego_nyc),
    ("Priya (Tokyo)", priya_tokyo),
]:
    print(f"{name}: {dt.strftime('%Y-%m-%d %H:%M %Z (UTC%z)')}")
Amara (Berlin): 2026-01-12 15:00 CET (UTC+0100)
Diego (New York): 2026-01-12 09:00 EST (UTC-0500)
Priya (Tokyo): 2026-01-12 23:00 JST (UTC+0900)

Three different wall-clock times, one shared instant. %Z prints the zone’s abbreviation (CET, EST, JST) and %z prints its numeric offset — both come from the ZoneInfo doing the lookup, not from anything you typed in.

Why the Offset Moves: DST Across the Year

Schedule the same stand-up for a July date, six months later, and convert it the same way:

standup_july_utc = datetime(2026, 7, 13, 14, 0, tzinfo=ZoneInfo("UTC"))

for label, base in [("January", standup_utc), ("July", standup_july_utc)]:
    b = base.astimezone(ZoneInfo("Europe/Berlin"))
    n = base.astimezone(ZoneInfo("America/New_York"))
    t = base.astimezone(ZoneInfo("Asia/Tokyo"))
    print(f"{label}: Berlin {b.strftime('%H:%M %Z')} | New York {n.strftime('%H:%M %Z')} | Tokyo {t.strftime('%H:%M %Z')}")
January: Berlin 15:00 CET | New York 09:00 EST | Tokyo 23:00 JST
July: Berlin 16:00 CEST | New York 10:00 EDT | Tokyo 23:00 JST

Same 14:00 UTC instant, but Amara’s and Diego’s local meeting time both moved forward an hour, while Priya’s didn’t move at all. That’s because Germany and the US observe daylight saving time (Berlin switches from CET to CEST, New York from EST to EDT) and Japan doesn’t. You can see it directly in the raw offsets:

print(standup_utc.astimezone(ZoneInfo("Europe/Berlin")).utcoffset())
print(standup_july_utc.astimezone(ZoneInfo("Europe/Berlin")).utcoffset())
print(standup_utc.astimezone(ZoneInfo("Asia/Tokyo")).utcoffset())
print(standup_july_utc.astimezone(ZoneInfo("Asia/Tokyo")).utcoffset())
1:00:00
2:00:00
9:00:00
9:00:00

Berlin’s offset from UTC grew from one hour to two; Tokyo’s stayed at nine hours in both runs. This is exactly the case a fixed timezone(timedelta(hours=1)) gets wrong — it has no way to know that the rule for Berlin changes partway through the year. The official zoneinfo documentation lists the full IANA database this lookup is built on, including every named zone you can pass to ZoneInfo.

Unix Timestamps: A Number With No Location

Monitoring systems and APIs often hand you a raw Unix timestamp — seconds since 1970-01-01 UTC — instead of a formatted string. Rivet Labs’ alerting tool logs one 13 minutes before the January stand-up:

alert_ts = 1768225642
alert_utc = datetime.fromtimestamp(alert_ts, tz=ZoneInfo("UTC"))
alert_local = alert_utc.astimezone(ZoneInfo("America/New_York"))
print(alert_ts)
print(alert_utc)
print(alert_local)
print(alert_local.timestamp())
print(alert_local.timestamp() == alert_utc.timestamp())
1768225642
2026-01-12 13:47:22+00:00
2026-01-12 08:47:22-05:00
1768225642.0
True

fromtimestamp(alert_ts, tz=ZoneInfo("UTC")) anchors the number to UTC first; converting it to New York afterward changes the display, not the underlying instant — .timestamp() round-trips back to the exact same number regardless of which zone’s datetime you call it on, because a timestamp is a property of the instant, not of any particular clock.

Parsing ISO 8601 with fromisoformat

APIs and log files that emit timestamps almost always use ISO 8601 — the 2026-01-12T15:00:00+01:00 shape. datetime.fromisoformat() parses it directly, no format string needed, and .isoformat() writes it back out:

iso_text = amara_berlin.isoformat()
print(iso_text)
parsed_back = datetime.fromisoformat(iso_text)
print(parsed_back)
print(parsed_back == amara_berlin)
2026-01-12T15:00:00+01:00
2026-01-12 15:00:00+01:00
True

Since Python 3.11, fromisoformat() also accepts the trailing Z that APIs commonly use as shorthand for +00:00:

api_text = "2026-01-12T14:00:00Z"
parsed_api = datetime.fromisoformat(api_text)
print(parsed_api)
print(parsed_api.tzinfo)
2026-01-12 14:00:00+00:00
UTC

(The outputs in this post come from Python 3.13. On Python 3.10 and earlier, fromisoformat() doesn’t understand a bare Z suffix and raises ValueError — you’d need to replace it with +00:00 first, e.g. api_text.replace("Z", "+00:00").)

Three Gotchas Worth Knowing

The same local wall-clock time can mean two different instants, and == won’t warn you. When clocks “fall back” for the end of DST, one local hour happens twice. US clocks fall back on November 1st, 2026, so 01:30 occurs once before the switch and once after:

before_fallback = datetime(2026, 11, 1, 1, 30, tzinfo=ZoneInfo("America/New_York"), fold=0)
after_fallback = datetime(2026, 11, 1, 1, 30, tzinfo=ZoneInfo("America/New_York"), fold=1)
print(before_fallback.astimezone(ZoneInfo("UTC")))
print(after_fallback.astimezone(ZoneInfo("UTC")))
print(before_fallback == after_fallback)
2026-11-01 05:30:00+00:00
2026-11-01 06:30:00+00:00
True

The fold argument (0 for the first occurrence, 1 for the second) is the only thing distinguishing them, and it correctly converts to two different UTC instants an hour apart — but Python’s == treats fold-only differences as equal anyway, by design, for backward compatibility with code written before fold existed. If you’re comparing times near a DST boundary, compare .astimezone(ZoneInfo("UTC")) values, never the aware datetimes directly.

A raw Unix timestamp isn’t tied to any location — fromtimestamp() without tz= silently uses your machine’s local timezone. This machine happens to run three and a half hours ahead of UTC, so the same alert_ts from earlier prints differently with no tz argument:

naive_from_ts = datetime.fromtimestamp(alert_ts)
print(naive_from_ts, naive_from_ts.tzinfo)
2026-01-12 17:17:22 None

That’s not 13:47:22 or 08:47:22 from before — it’s whatever alert_ts looks like on this machine’s system clock, and tzinfo is None, so nothing about the result records which clock was used. Running this same line on a different machine gives a different wall-clock time for the identical number. Always pass tz= explicitly when converting a timestamp.

ZoneInfo needs its own database, and an unknown key fails loudly. A typo’d or unsupported zone name raises immediately, rather than silently falling back to UTC or your system zone:

from zoneinfo import ZoneInfoNotFoundError

try:
    ZoneInfo("Not/AZone")
except ZoneInfoNotFoundError as exc:
    print(f"ZoneInfoNotFoundError: {exc}")
ZoneInfoNotFoundError: 'No time zone found with key Not/AZone'

On Linux and macOS this database usually ships with the OS; on some minimal environments (and on Windows) it doesn’t, and you need pip install tzdata before any ZoneInfo("...") call will resolve a real zone name.

Wrapping Up

Everything here comes down to keeping the instant and the display separate:

  • ZoneInfo("Region/City") → a rulebook that looks up the correct offset for a given date, unlike a fixed timezone(timedelta(...))
  • .astimezone() → re-displays the same instant through a different rulebook; it never changes when something happened
  • .fromtimestamp(ts, tz=...) and .timestamp() → convert a location-free Unix number to and from an anchored instant; always pass tz= explicitly
  • .fromisoformat() / .isoformat() → parse and emit the 2026-01-12T15:00:00+01:00 shape APIs use, including a bare Z on Python 3.11+
  • fold → distinguishes the two occurrences of an ambiguous local hour during a DST fall-back; compare via UTC, not ==, near those boundaries

If you want to build these skills into a fuller Python foundation — file handling, error handling, and the rest of the standard library alongside dates and timezones — the Working with Dates, Times, and Timezones lesson in our free Python for Data Analytics course picks up exactly where this post leaves off.

More tutorials