PYTHON / ADVANCED PYTHON
Dates, times, and time zones
Work with aware datetimes in Python using zoneinfo, convert between zones, and reason correctly about DST gaps, folds, and durations.
What you will learn
- Attach real zones with ZoneInfo and convert instants using astimezone
- Do duration math in UTC; same-zone timedelta math is wall-clock math
- Use fold to distinguish the two 01:30s when a zone falls back
- Replace datetime.utcnow() with datetime.now(timezone.utc)
Understanding Dates, times, and time zones
A datetime object with tzinfo set to None is naive: it holds numbers like 2026-03-08 01:30 but no rule for turning them into a point in time. An aware datetime pairs the same wall-clock reading with a tzinfo that can answer utcoffset() and tzname() for that particular moment. ZoneInfo("America/New_York") is such a rule set, loaded from the IANA database, so it knows the offset was -05:00 before 2026-03-08 02:00 and -04:00 after. A fixed timezone(timedelta(hours=-5)) is also aware but frozen, which makes it wrong for roughly half the year in any zone that observes DST.
Because an aware datetime stores wall-clock fields plus rules, arithmetic has two different meanings and Python picks based on the tzinfo objects involved. Adding a timedelta keeps the same tzinfo and adds to the wall-clock fields, then recomputes the offset, so 01:30 EST plus two hours becomes 03:30 EDT, only one real hour later. Subtracting two datetimes that share the same tzinfo object also compares wall clocks and ignores fold; subtracting across different zones converts to UTC first. If you want elapsed physical time, call astimezone(timezone.utc) on both endpoints and subtract there.
DST creates local times that do not exist and local times that happen twice. When the clock springs forward, 02:30 in New York on 2026-03-08 is skipped, and zoneinfo resolves it using the pre-transition offset rather than raising. When it falls back, 01:30 on 2026-11-01 occurs at both 05:30 UTC and 06:30 UTC, and the fold attribute selects which one: fold=0 is the first (EDT), fold=1 is the second (EST). Storing that local time without fold discards an hour of information permanently, which is why systems keep UTC internally and treat zones as a presentation and input concern.
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
start = datetime(2026, 3, 8, 1, 30, tzinfo=ny)
print(start.isoformat(), start.tzname())
# timedelta on an aware datetime moves the wall clock, not the instant
wall = start + timedelta(hours=2)
print(wall.isoformat(), wall.tzname())
start_utc = start.astimezone(timezone.utc)
print("real time elapsed:", wall.astimezone(timezone.utc) - start_utc)
# to move two real hours, add in UTC and convert back
real = (start_utc + timedelta(hours=2)).astimezone(ny)
print(real.isoformat(), real.tzname())An aware datetime is a wall-clock reading plus offset rules, so only conversion to UTC turns it into an unambiguous instant you can measure with.
Worked examples
The hour that happens twice
Shows how fold distinguishes the two occurrences of 01:30 when New York leaves DST, and that they still compare as equal.
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
ny = ZoneInfo("America/New_York")
first = datetime(2026, 11, 1, 1, 30, tzinfo=ny) # fold defaults to 0
second = first.replace(fold=1)
for dt in (first, second):
print(dt.isoformat(), dt.tzname(), dt.astimezone(timezone.utc).isoformat())
print("equal wall clock:", first == second)
print("same instant:", first.astimezone(timezone.utc) == second.astimezone(timezone.utc))Example explained
Line 1replace(fold=1) changes nothing about the wall-clock fields; it only tells the zone to use the post-transition offset.
Line 2isoformat() prints the resolved offset but never the fold flag, so the two lines differ only by -04:00 versus -05:00.
Line 3first == second is True because both share the same tzinfo object, and same-zone comparison ignores fold entirely.
Line 4After astimezone(timezone.utc) the two values are genuinely different instants an hour apart, so the equality flips to False.
Parsing, converting, and mixing naive with aware
Reads an ISO 8601 timestamp, renders it in Tokyo, and shows the TypeError from mixing a naive value into aware arithmetic.
from datetime import datetime
from zoneinfo import ZoneInfo
utc_dt = datetime.fromisoformat("2026-07-04T12:00:00+00:00")
print(utc_dt, utc_dt.utcoffset())
tokyo = utc_dt.astimezone(ZoneInfo("Asia/Tokyo"))
print(tokyo.isoformat())
print(tokyo.strftime("%Y-%m-%d %H:%M %Z"))
naive = datetime.fromisoformat("2026-07-04T12:00:00")
print(naive.tzinfo)
try:
naive - utc_dt
except TypeError as e:
print("TypeError:", e)Example explained
Line 1fromisoformat reads the trailing +00:00 and produces an aware value, so utcoffset() returns a zero timedelta instead of None.
Line 2astimezone recomputes the wall-clock fields for the target zone, turning 12:00 UTC into 21:00 with a +09:00 offset.
Line 3%Z asks the tzinfo for the abbreviation in force at that moment, which zoneinfo reports as JST.
Line 4The same string without an offset yields tzinfo None, and Python refuses to subtract it from an aware value rather than guessing a zone.
Important notes
zoneinfo reads the operating system's tz database; on Windows or slim containers there is none, so ZoneInfo raises ZoneInfoNotFoundError until you install the tzdata package.
Abbreviations are output-only in practice: strptime cannot turn "EST" back into a zone because such abbreviations are ambiguous across countries, so always transmit UTC offsets or IANA zone names.
Common mistakes
Calling datetime.utcnow(), which returns a naive value whose fields happen to be UTC; comparing it to an aware datetime raises TypeError, and passing it to astimezone silently treats it as local time. It is deprecated since 3.12 in favour of datetime.now(timezone.utc).
Encoding a zone as a fixed offset such as timezone(timedelta(hours=-5)) or a hardcoded -05:00 string; every timestamp produced during daylight saving is off by exactly one hour and the bug only appears for part of the year.
Adding timedelta(days=1) to an aware local datetime and assuming 24 real hours passed; across a spring-forward day the gap is 23 hours, so schedulers drift and duration checks silently fail.
Try it yourself
Change, predict, then run
A flight leaves New York at 2026-11-01 00:30 local time and is in the air for exactly three hours. Build the departure as an aware datetime, add the duration in UTC, then print the arrival in both UTC and America/New_York and confirm the local wall clock advanced only two hours.
Open the Python workspaceCheck your understanding
A reminder is stored as datetime(2026, 3, 7, 8, 0, tzinfo=ZoneInfo("America/New_York")) and the code computes dt + timedelta(days=1). What does the result represent?
- 2026-03-08 08:00 local time, which is 23 real hours after the original instant
- 2026-03-08 08:00 local time, which is exactly 24 real hours after the original instant
- 2026-03-08 09:00 local time, because the shift to EDT is added to the wall clock
- A raised exception, because that calendar day is only 23 hours long
Show answer
Adding a timedelta to an aware datetime keeps the tzinfo and adds to the wall-clock fields, so the result is 08:00 on 8 March, but the offset is recomputed as EDT (-04:00) instead of EST (-05:00); in UTC the two values are 13:00 and 12:00, one hour apart on the calendar day, so only 23 real hours elapsed. Option two is the common assumption that timedelta always means absolute time, which holds only if you convert to UTC before adding. Nothing raises: zoneinfo resolves offsets rather than rejecting the arithmetic.