Time Zones
Time zones turn a naive clock reading into a real, unambiguous moment — but daylight saving time and offset-vs-zone confusion make them one of the trickiest parts of working with dates.
A naive datetime is just numbers; a time zone is what anchors those numbers to an actual, unambiguous moment in real time. This topic covers the offset-versus-timezone distinction, UTC as the universal reference, the standard-library zoneinfo module and the IANA time zone database it draws from, and how daylight saving time creates edge cases that a fixed offset cannot handle correctly.
Why Time Zones Matter
"2:30 PM" means nothing on its own -- 2:30 PM in New York and 2:30 PM in Tokyo are different moments in real time, roughly 13-14 hours apart. Any application that stores, compares, or displays times across more than one location must model time zones correctly, or it will silently produce wrong schedules, wrong deadlines, and wrong ordering of events.
Offset Versus Timezone: A Critical Distinction
A UTC *offset* (like +05:30 or -08:00) is a fixed number: how far a clock reading is from UTC at one instant. A *timezone* (like "Asia/Kolkata" or "America/Los_Angeles") is a set of *rules* -- which offset applies, and how that offset changes over the year (e.g. for daylight saving time) and over history (governments change rules occasionally).
datetime.timezone (built into the datetime module) represents a *fixed offset only* -- it never changes, so it cannot correctly represent a real-world location that observes daylight saving time. zoneinfo.ZoneInfo represents a real, rule-based *timezone*, correctly varying its effective offset across the year.
New York is UTC-5 in winter (EST) but UTC-4 in summer (EDT, daylight saving) -- a fixed timezone(timedelta(hours=-5)) gets July wrong; ZoneInfo gets it right automatically.
from datetime import datetime, timezone, timedelta
from zoneinfo import ZoneInfo
fixed_offset = datetime(2024, 7, 15, 12, 0, tzinfo=timezone(timedelta(hours=-5)))
real_zone = datetime(2024, 7, 15, 12, 0, tzinfo=ZoneInfo("America/New_York"))
print(fixed_offset.utcoffset()) # always -5:00, no matter the date
print(real_zone.utcoffset()) # -4:00 in July (EDT, daylight saving in effect)UTC as the Universal Reference
UTC (Coordinated Universal Time) is the reference point every other time zone is defined relative to, and it does not observe daylight saving time -- it is a single, stable timeline. datetime.timezone.utc gives you the UTC fixed-offset tzinfo object built into the standard library, no zoneinfo needed.
Best practice, reiterated from the Date & Time topic: store and compute with aware UTC datetimes internally; convert to a specific local zone only when displaying to a user.
from datetime import datetime, timezone
now_utc = datetime.now(timezone.utc)
print(now_utc) # e.g. 2026-09-08 10:00:00+00:00
print(now_utc.utcoffset()) # timedelta(0)zoneinfo and the IANA Time Zone Database
zoneinfo (standard library since Python 3.9) provides ZoneInfo, which looks up real-world time zone rules by IANA name -- strings like "America/New_York", "Europe/London", "Asia/Tokyo", "UTC". These names come from the IANA Time Zone Database (also called tz database or Olson database), the standard, actively maintained source of time zone rule data used across the software industry.
astimezone() converts an aware datetime to the equivalent moment expressed in a different zone -- the underlying instant in real time doesn't change, only its displayed representation.
CPython implementation detail: zoneinfo relies on a system-provided or bundled copy of the IANA tz database; on platforms that don't ship one, the third-party tzdata package (pip install tzdata) provides it. This is a deployment/data-source detail, not something the language guarantees.
from datetime import datetime
from zoneinfo import ZoneInfo
meeting = datetime(2024, 6, 1, 9, 0, tzinfo=ZoneInfo("America/New_York"))
print(meeting) # 2024-06-01 09:00:00-04:00
print(meeting.astimezone(ZoneInfo("Europe/London")))
print(meeting.astimezone(ZoneInfo("Asia/Tokyo")))Daylight Saving Time (DST)
Many regions shift their clocks forward by an hour in spring ("spring forward") and back in autumn ("fall back") to make better use of daylight. ZoneInfo automatically accounts for this: the same zone name yields a different effective UTC offset depending on the date, as seen above with New York's EST/EDT switch. Regions that don't observe DST (e.g. most of Arizona, Japan, most of the tropics) simply have a constant offset year-round.
DST transitions create two structurally different problem moments each year: a *gap* (in spring, when clocks skip forward, some local times never occur) and a *fold* (in autumn, when clocks repeat an hour, some local times occur twice).
DST Edge Cases: Gaps and Folds
When clocks spring forward, the local time between the gap (e.g. 2:00 AM to 3:00 AM in a zone that skips from 2:00 to 3:00) never exists on the wall clock -- an aware datetime constructed with such a nonexistent local time is technically representable but does not correspond to a real moment, and zoneinfo will typically resolve it by treating it as if standard adjustment rules applied (effectively shifting it), which can be surprising.
The fold attribute (PEP 495, Python 3.6+) disambiguates the earlier (fold=0, default) versus later (fold=1) occurrence of a repeated local time during a fall-back transition.
The safest way to avoid gap/fold ambiguity entirely: do scheduling and storage in UTC, and only convert to local wall-clock time for display. A UTC instant is never ambiguous.
from datetime import datetime
from zoneinfo import ZoneInfo
# In 2024, US clocks sprang forward at 2:00 AM on March 10 to 3:00 AM.
# 2:30 AM on that date never actually occurred locally.
gap_time = datetime(2024, 3, 10, 2, 30, tzinfo=ZoneInfo("America/New_York"))
print(gap_time.utcoffset()) # resolved per zoneinfo's rules -- verify, don't assume
# In autumn, clocks "fall back" and 1:30 AM occurs twice.
# Use fold to disambiguate which occurrence is meant:
fold_time_first = datetime(2024, 11, 3, 1, 30, tzinfo=ZoneInfo("America/New_York"), fold=0)
fold_time_second = datetime(2024, 11, 3, 1, 30, tzinfo=ZoneInfo("America/New_York"), fold=1)
print(fold_time_first.utcoffset(), fold_time_second.utcoffset())Converting Between Zones
.astimezone(zone) converts an aware datetime to the equivalent moment in another zone -- it changes the displayed date/time and offset, not the underlying instant. Converting a *naive* datetime with .astimezone() (no argument, or on a naive value) assumes the system's local time zone, which is rarely what you want in a program processing data from multiple sources.
from datetime import datetime
from zoneinfo import ZoneInfo
utc_time = datetime(2024, 6, 1, 13, 0, tzinfo=ZoneInfo("UTC"))
ny_time = utc_time.astimezone(ZoneInfo("America/New_York"))
tokyo_time = utc_time.astimezone(ZoneInfo("Asia/Tokyo"))
print(utc_time, ny_time, tokyo_time)
print(utc_time == ny_time == tokyo_time) # True -- same instant, different representationPractical Usage
Typical rules of thumb: accept and store timestamps in UTC (e.g. in a database column typed as timezone-aware or explicitly documented as UTC); accept a user's IANA zone name (from their profile or browser) separately, and convert to it only when rendering for display; never store a raw UTC offset as if it were a timezone, since it can't correctly represent DST-observing regions across the year.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def to_user_local(utc_dt: datetime, iana_name: str) -> str:
if utc_dt.tzinfo is None:
raise ValueError("utc_dt must be timezone-aware")
local = utc_dt.astimezone(ZoneInfo(iana_name))
return local.strftime("%Y-%m-%d %H:%M %Z")
event_utc = datetime(2024, 12, 25, 18, 0, tzinfo=timezone.utc)
print(to_user_local(event_utc, "America/Los_Angeles"))
print(to_user_local(event_utc, "Asia/Kolkata"))Edge Cases
IANA zone rules change occasionally (a country changes its DST policy, or its offset) -- because zoneinfo reads from the live tz database, this is handled automatically as the database is updated, unlike a hardcoded fixed offset which can silently become wrong when rules change.
"UTC" as an IANA name and datetime.timezone.utc both represent UTC but are technically different tzinfo implementations; they compare equal for datetime purposes but aren't the identical object -- don't rely on is when checking for UTC.
Not all fixed-offset abbreviations are unambiguous: "CST" alone could mean Central Standard Time (US) or China Standard Time depending on context -- always prefer IANA zone names ("America/Chicago") over ambiguous abbreviations.
Best Practices
Use ZoneInfo with IANA names for anything that must be correct across DST transitions and over multiple years; reserve datetime.timezone fixed offsets for genuinely fixed-offset needs (UTC itself, or protocols that only speak in offsets). Store timestamps in UTC. Keep the user's IANA zone name as a separate, explicit piece of data rather than baking a numeric offset into stored data. Test DST-transition dates explicitly -- they're where timezone bugs live.
Complete Example
The meeting is captured once, converted to UTC for storage, and can be correctly rendered for any user's zone -- the DST math for both LA and Berlin is handled automatically by ZoneInfo.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
def schedule_meeting(local_time_str: str, user_zone: str) -> datetime:
"""Parse a naive local time string in the user's zone and store as UTC."""
naive = datetime.fromisoformat(local_time_str)
aware_local = naive.replace(tzinfo=ZoneInfo(user_zone))
return aware_local.astimezone(timezone.utc)
def display_for_user(utc_dt: datetime, user_zone: str) -> str:
local = utc_dt.astimezone(ZoneInfo(user_zone))
return local.strftime("%Y-%m-%d %H:%M %Z (%z)")
stored_utc = schedule_meeting("2024-07-04T09:00:00", "America/Los_Angeles")
print("stored:", stored_utc)
print("for LA user:", display_for_user(stored_utc, "America/Los_Angeles"))
print("for Berlin user:", display_for_user(stored_utc, "Europe/Berlin"))Quick Reference
timezone(timedelta(...)) fixed offset only, no DST (stdlib, always available)
timezone.utc the fixed UTC offset
ZoneInfo("Area/City") real, rule-based zone with DST (Python 3.9+)
"America/New_York" etc. IANA time zone database names
dt.astimezone(zone) convert an aware datetime to another zone
dt.utcoffset() effective offset at this specific datetime
fold=0 / fold=1 disambiguate a repeated local time (fall-back)
gap (spring forward) some local times never occur
fold (fall back) some local times occur twice
Best practice: store UTC, keep IANA zone name separately, convert only for displayKey Takeaways
An offset is a fixed number; a timezone is a set of rules that can change the effective offset by date (DST) and over history.
datetime.timezone models fixed offsets only; zoneinfo.ZoneInfo (3.9+) models real IANA time zones, correctly handling DST.
UTC is the stable reference timeline with no DST; store and compute with aware UTC datetimes internally.
DST transitions create gaps (spring, some local times never occur) and folds (autumn, some local times occur twice); fold disambiguates the latter.
Always prefer IANA zone names ("America/Chicago") over ambiguous abbreviations ("CST") or hardcoded numeric offsets.
Convert to a user's local zone only at the display step, keeping their IANA zone name as separate, explicit data.
ny = datetime(2024, 7, 4, 9, 0, tzinfo=timezone(timedelta(hours=-5))) ny = datetime(2024, 7, 4, 9, 0, tzinfo=ZoneInfo("America/New_York"))
A fixed -5:00 offset is wrong for New York in July, which observes EDT (-4:00) due to daylight saving; ZoneInfo applies the correct rule for the given date automatically.
local_time = datetime(2024, 3, 10, 2, 30) # assumed valid without checking DST
2:30 AM on the US spring-forward date in 2024 never actually occurred on local clocks in that zone; constructing and trusting such a time without awareness of the gap can silently misrepresent the intended moment.
if user_dt.tzinfo is timezone.utc: ...
if user_dt.utcoffset() == timedelta(0): ... # or compare using == on aware datetimes
Different UTC tzinfo objects (timezone.utc vs ZoneInfo("UTC")) are not necessarily the same object, so is comparisons can fail even though they represent the same offset; compare offsets or use == on the datetimes themselves.
What is the difference between a UTC offset and a timezone?
An offset is a single fixed number (how far from UTC right now); a timezone is a set of rules that determines which offset applies on a given date, including how it changes for daylight saving time and over history.
Why does datetime.timezone(timedelta(hours=-5)) give the wrong result for New York in July?
New York observes daylight saving time and is UTC-4 (EDT) in July, not the UTC-5 (EST) that fixed offset always represents; a fixed offset can't vary by date the way a real timezone rule can.
What is the IANA time zone database, and what do names like 'America/New_York' come from?
It's the standard, actively maintained database of real-world time zone rules (also called the tz database or Olson database) used across the software industry; ZoneInfo looks up its rules by these Area/City names.
What is a DST 'gap', and what is a DST 'fold'?
A gap is a range of local wall-clock times that never occur because clocks jumped forward (spring forward); a fold is a range of local times that occur twice because clocks moved backward (fall back).
What does the fold attribute on a datetime do?
It disambiguates which of the two occurrences of a repeated local time (during a fall-back transition) is meant: fold=0 is the earlier occurrence (default), fold=1 is the later one.
Why is it recommended to store timestamps in UTC and convert to local time only for display?
A UTC instant is never ambiguous and has no DST to worry about; converting to a specific IANA zone only when rendering for a user avoids storing an offset that could become wrong if that zone's DST rules or the base offset change.
Why should you prefer an IANA zone name like 'America/Chicago' over an abbreviation like 'CST'?
Abbreviations like CST are ambiguous (Central Standard Time in the US vs China Standard Time) and don't encode DST behavior, while an IANA name uniquely identifies a real location's full, versioned set of time zone rules.
Write a function hours_between(dt1, dt2) that returns the absolute number of hours between two aware datetimes, converting neither to a common zone manually (rely on aware subtraction handling it).
from datetime import datetime def hours_between(dt1: datetime, dt2: datetime) -> float: if dt1.tzinfo is None or dt2.tzinfo is None: raise ValueError("both datetimes must be timezone-aware") return abs((dt2 - dt1).total_seconds()) / 3600 from zoneinfo import ZoneInfo a = datetime(2024, 6, 1, 9, 0, tzinfo=ZoneInfo("America/New_York")) b = datetime(2024, 6, 1, 9, 0, tzinfo=ZoneInfo("Asia/Tokyo")) print(hours_between(a, b))
13.0 -- aware datetime subtraction automatically accounts for each datetime's UTC offset, so the two 9:00 AM local times in different zones correctly resolve to a 13-hour real-time gap.
What does this print, and why?
False. Chicago observes daylight saving time, so its UTC offset is -6:00 (CST) in January but -5:00 (CDT) in July -- ZoneInfo correctly returns different offsets for the same zone depending on the date.
Write a function is_dst_active(dt, iana_name) that reports whether daylight saving time is currently in effect for a given naive local datetime interpreted in the given IANA zone, using the dst() method.
from datetime import datetime, timedelta from zoneinfo import ZoneInfo def is_dst_active(naive_dt: datetime, iana_name: str) -> bool: aware = naive_dt.replace(tzinfo=ZoneInfo(iana_name)) offset = aware.dst() return offset is not None and offset != timedelta(0) print(is_dst_active(datetime(2024, 1, 15, 12, 0), "America/Chicago")) # False print(is_dst_active(datetime(2024, 7, 15, 12, 0), "America/Chicago")) # True
False, then True -- the dst() method returns the DST adjustment currently in effect (zero or None when not observed/active, a positive timedelta such as one hour when active), so checking it against timedelta(0) tells you whether DST is in effect for that date in that zone.
from datetime import datetime
from zoneinfo import ZoneInfo
winter = datetime(2024, 1, 15, 12, 0, tzinfo=ZoneInfo("America/Chicago"))
summer = datetime(2024, 7, 15, 12, 0, tzinfo=ZoneInfo("America/Chicago"))
print(winter.utcoffset() == summer.utcoffset())