A job must run every day at 09:00 in Tokyo, and your logs record when each run happened. These are two different kinds of time. A log entry is an instant, one point on the timeline, the same everywhere in the world. "09:00 in Tokyo" is a wall-clock time, what a clock on the wall in that place shows. Store instants in UTC. For a schedule, keep the wall-clock time and the zone name, and turn them into an instant only when you need one.
Reject timestamps that have no offset
A datetime with a time zone attached is aware, and it names one
instant. One without a zone is naive. A naive 2026-07-28T10:30 is a
different instant in London than in Tokyo. mypy cannot tell the two kinds
apart, because both are datetime, so check when you parse:
from datetime import datetime, timezone
def parse_utc(text: str) -> datetime:
value = datetime.fromisoformat(text)
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("timestamp needs an offset")
return value.astimezone(timezone.utc)
print(parse_utc("2026-07-28T12:30:00+02:00")) # 2026-07-28 10:30:00+00:00
print(parse_utc("2026-07-28T10:30:00Z")) # 2026-07-28 10:30:00+00:00
Get the current time with datetime.now(timezone.utc). A common mistake is
datetime.utcnow(), which Python 3.12 deprecated. It returns a naive value.
Later code reads that value as local time, and the result is off by your
machine's UTC offset.
Convert with astimezone, not replace
from zoneinfo import ZoneInfo
tokyo = ZoneInfo("Asia/Tokyo")
noon_utc = datetime(2026, 7, 28, 12, tzinfo=timezone.utc)
print(noon_utc.astimezone(tokyo)) # 2026-07-28 21:00:00+09:00
print(noon_utc.replace(tzinfo=tokyo)) # 2026-07-28 12:00:00+09:00
ZoneInfo("Asia/Tokyo") loads a zone's rules, including its daylight
saving changes, from the IANA time zone database. astimezone(zone) shows
the same instant on another zone's clock. replace(tzinfo=zone) keeps the
clock reading and swaps the zone, so it names a different instant, here
nine hours earlier. On a naive value, astimezone assumes your computer's
own zone, which is why the parser rejects naive input first.
Daylight saving makes some days shorter
from datetime import timedelta
new_york = ZoneInfo("America/New_York")
start = datetime(2026, 3, 7, 12, tzinfo=new_york)
next_noon = start + timedelta(days=1)
print(next_noon) # 2026-03-08 12:00:00-04:00
print(next_noon.astimezone(timezone.utc) - start.astimezone(timezone.utc)) # 23:00:00
Adding timedelta(days=1) to a zoned datetime moves the wall clock, from
noon to noon. New York moved its clocks forward on 8 March 2026, so that
day is only 23 hours long. For a fixed duration, such as "24 hours from
now", do the arithmetic in UTC. For "the same time tomorrow", add days to
the local date. Convert both values to UTC before you subtract them, as
above. Subtracting two datetimes in the same zone compares their clock
readings and gives one day.
Some wall-clock times happen twice, some never
On 1 November 2026, New York clocks go back from 02:00 to 01:00, so 01:30
happens twice. The fold attribute picks one, fold=0 for the first and
fold=1 for the second. On 8 March 2026, clocks jump from 02:00 to 03:00,
so 02:30 never happens. Python lets you build it anyway.
first = datetime(2026, 11, 1, 1, 30, tzinfo=new_york) # fold=0
second = first.replace(fold=1)
print(first.astimezone(timezone.utc)) # 2026-11-01 05:30:00+00:00
print(second.astimezone(timezone.utc)) # 2026-11-01 06:30:00+00:00
gap = datetime(2026, 3, 8, 2, 30, tzinfo=new_york)
print(gap.astimezone(timezone.utc).astimezone(new_york)) # 2026-03-08 03:30:00-04:00
To find out whether a wall-clock time exists, convert it to UTC and back. If the clock reading changed, as 02:30 changed to 03:30, the time does not exist.
Do not compare first and second directly. With the same zone object,
Python compares their clock readings and ignores fold, so
first == second is True. Compare UTC values when the order of instants
matters.
Decide what your schedule does
A daily schedule needs a rule for both cases. The exercise skips a day on which the time does not exist, and uses only the first of a repeated time. It steps through dates in the target zone and builds the wall-clock time on each one. It returns the first time that exists and is later than the reference instant.
Check your understanding Why does the search step one calendar date at a time instead of adding 24 hours? Show answer Hide answer
The schedule is a wall-clock time. Across a daylight saving change, 24 hours after 09:00 is 08:00 or 10:00 on the local clock. Stepping by date and building 09:00 on each date keeps the clock time fixed. The conversion to UTC then gives the right instant for each day.
Going further
ZoneInforeads zone data from the operating system, or from thetzdatapackage when the system has none, which is common on Windows. An unknown zone name raisesZoneInfoNotFoundError.- Older code uses the third-party
pytzpackage, which attaches zones withlocalize(). When you move it tozoneinfo, check every place that attaches a zone, not only the imports. - See the zoneinfo documentation.