Skip to content
← All Python articles

Python explainer

Store Times in UTC and Schedule in Local Time

About 4 min to read. Allow extra time to work through examples.

Tell an instant from a wall-clock time, parse timestamps into UTC, and handle the local times that daylight saving skips or repeats.

This article uses Python. You can make it your practice mode to browse related content across the site.

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

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

  • ZoneInfo reads zone data from the operating system, or from the tzdata package when the system has none, which is common on Windows. An unknown zone name raises ZoneInfoNotFoundError.
  • Older code uses the third-party pytz package, which attaches zones with localize(). When you move it to zoneinfo, check every place that attaches a zone, not only the imports.
  • See the zoneinfo documentation.

step 54 of 55

Everyday Standard Library Tools

Up next Find the Next Daily Run in a Time Zone