Jitsumi
Back to the blog

Scheduling Across Time Zones: When UTC Alone Isn’t Enough

A 9am reminder and a 24-hour interval can diverge when clocks change. Learn how to preserve scheduling intent, resolve ambiguous times and test the behaviour in Python.

Conceptual illustration of an analogue clock with overlapping glass silhouettes and shadows, representing repeated local times.

Imagine a reminder that should run at 9am in New York every day. You convert its first run to UTC, add 24 hours for the next one, and save the result. It looks sensible until the clocks go back and the reminder arrives at 8am.

The application has preserved an elapsed interval while the user expected a local clock time.

Use UTC to identify individual occurrences. For a schedule tied to local time, also preserve the local date and time, a named time zone, the recurrence rule, and the policy for missing or repeated times. A schedule deliberately defined as “every 24 hours” has a different requirement.

This guide explains that distinction, demonstrates it with runnable Python, and turns it into a practical storage and testing approach. The same design questions apply to booking systems, scheduled reports and calendar integrations.

First decide what “the same time” means

Before choosing a database column or date library, write down the promise the application makes. These are different requirements:

What the user means

What the application should preserve

“This upload finished at this moment.”

An unambiguous instant, normally represented in UTC.

“Run again 24 hours after this run.”

The starting instant and an elapsed interval.

“Book 9am in New York on this date.”

The local date and time, the governing zone, any ambiguity decision, and a resolved instant.

“Send the report at 9am New York time every day.”

The local starting date and time, zone, recurrence and exception rules, and clock-change policies. Derive each occurrence.

“The event is all day on 8 March.”

A calendar date, with the relevant calendar context; do not silently invent a midnight-UTC appointment.

This is a design guide, not a universal database schema. A future event can be anchored to an exact UTC instant or to a local-time promise. The product needs to say which one takes priority if the rules change.

For recurring appointments, this distinction is established practice. RFC 5545’s recurrence guidance describes using a local start time with a time-zone reference to preserve the same local time across changes. Google Calendar’s API likewise requires a time zone when expanding recurring events. Its API separately represents all-day events with date fields.

An offset is a result; a named zone supplies rules

An instant identifies one point on the timeline. 2026-11-01T06:30:00Z is an instant expressed in UTC; the Z marks UTC.

An offset, such as -05:00, tells you how far a local clock reading is from UTC at that point. It does not tell you which region’s future clock changes to follow.

An IANA time-zone identifier, such as America/New_York, selects a set of rules. A time-zone library uses those rules to determine an offset for a particular date. A fixed -05:00 offset and America/New_York therefore express different information.

IANA documents the distinction between location-based rules and fixed offsets. Use a supported zone identifier when location-based scheduling matters. Abbreviations such as CST are a poor durable identifier because they can mean different things in different places.

Run the example: 9am tomorrow is 25 hours away

New York’s autumn transition provides a useful test. Under the 2026 US daylight-saving rules published by NIST, clocks move back on 1 November. The interval from 9am on 31 October to 9am on 1 November is consequently 25 elapsed hours.

The example requires Python 3.9 or later, which includes the standard zoneinfo module, and an available IANA database. On systems without one, including many Windows installations, install the first-party tzdata package in the environment running the example:

python -m pip install tzdata

Here, python must be your project’s interpreter. If installation succeeds but the example still cannot find its data, check that installation and execution use the same Python environment.

Python searches system zone data before falling back to the package. Installing a newer package does not automatically replace usable system data.

Save the following as schedule_time_demo.py, then run python schedule_time_demo.py with the same interpreter:

from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo

utc = timezone.utc
ny = ZoneInfo("America/New_York")
start = datetime(2026, 10, 31, 9, 0, tzinfo=ny)

next_morning = start + timedelta(days=1)

after_24_hours = (
    start.astimezone(utc) + timedelta(hours=24)
).astimezone(ny)

elapsed = (
    next_morning.astimezone(utc) - start.astimezone(utc)
)

print("Next local morning:", next_morning.isoformat())
print("After 24 hours:", after_24_hours.isoformat())
print("Elapsed hours to next morning:", elapsed.total_seconds() / 3600)

Output:

Next local morning: 2026-11-01T09:00:00-05:00
After 24 hours: 2026-11-01T08:00:00-05:00
Elapsed hours to next morning: 25.0

The first calculation keeps the next day’s 9am clock reading. The second adds a full 24 hours on the UTC timeline and converts the result back to New York time. Each answer is useful for a different promise.

There is a subtle Python detail here: `timedelta(days=1)` and `timedelta(hours=24)` are equal. The change in behaviour comes from where the arithmetic happens. Replacing days=1 with hours=24 while keeping the calculation in New York time does not make it elapsed-time arithmetic.

Python’s documented datetime operations retain the input’s tzinfo during addition. Subtracting two aware datetimes that share the same tzinfo also ignores their offset difference. That is why the example converts both endpoints to UTC before measuring elapsed hours.

The example uses 9am, which exists unambiguously on both dates. It demonstrates arithmetic, not a complete recurrence engine. Arbitrary user-selected times need the additional checks below.

Store the schedule separately from its next execution time

Keep what the user requested separately from the execution times derived from it:

  • The schedule definition: what the user asked to happen.
  • The resolved occurrence: the particular instant at which an instance should run.

For a daily local-time report, an illustrative definition could look like this:

{
  "local_start": "2026-10-31T09:00:00",
  "time_zone": "America/New_York",
  "frequency": "daily",
  "gap_policy": "skip",
  "overlap_policy": "earlier"
}

These are example application fields, not an iCalendar document or a payload for a particular scheduling API. The two policies are deliberate example choices; they need to match your product’s promise. A fuller model also needs any end date, exclusions and individual rescheduled occurrences.

The local value has no offset because its meaning is supplied by the accompanying zone and resolution policy. Do not pass it into an unrelated parser that silently assumes the server’s local zone.

An implementation can then:

  1. Find the next candidate date and clock time using the recurrence rule.
  2. Resolve that local value in the schedule’s named zone.
  3. Apply the agreed policy if the value is missing or ambiguous.
  4. Store the resulting UTC instant for dispatch, alongside the occurrence’s identity.

For this example, 31 October’s 9am occurrence resolves to 13:00 UTC. The next day’s 9am occurrence resolves to 14:00 UTC. Keeping only the first UTC value and repeatedly adding 24 hours would lose the original intent.

Keep the schedule’s governing zone separate from a viewer’s display zone. A colleague travelling abroad may want to see an appointment in their current zone. That display preference should not silently rewrite the appointment’s governing rule.

PostgreSQL’s timestamptz does not retain the original zone

The name can be misleading if you expect it to preserve every input detail. PostgreSQL’s date/time documentation states that timestamp with time zone, also called timestamptz, stores the value internally in UTC. It does not retain the originally supplied zone. Output is converted into the session’s configured zone.

That makes it useful for a resolved execution instant. Store the IANA identifier separately when it forms part of the schedule’s meaning. A local start value and recurrence fields preserve information that cannot be recovered from that instant alone.

Decide what to do when the clock repeats or skips a time

Most local dates and times resolve normally. Clock changes introduce two cases that deserve explicit behaviour.

A repeated time has two possible instants

In New York on 1 November 2026, 1:30am occurs twice. The earlier reading uses UTC−04:00 and corresponds to 05:30 UTC. The later reading uses UTC−05:00 and corresponds to 06:30 UTC.

The clock face alone cannot distinguish them:

New York’s 01:30 on 1 November 2026 maps to 05:30 UTC with fold 0 and 06:30 UTC with fold 1, one hour apart.

*Both readings say 1:30am in New York on 1 November 2026. Their UTC instants are one hour apart.*

In Python, fold=0 chooses the earlier occurrence and fold=1 chooses the later one. That chronological distinction is the meaning of fold; it should not be treated as a universal “daylight saving on/off” switch. PEP 495 defines the disambiguation behaviour.

Using the ny and utc values from the first example:

first = datetime(2026, 11, 1, 1, 30, tzinfo=ny, fold=0)
second = datetime(2026, 11, 1, 1, 30, tzinfo=ny, fold=1)

print(first.astimezone(utc).isoformat())
print(second.astimezone(utc).isoformat())

Output:

2026-11-01T05:30:00+00:00
2026-11-01T06:30:00+00:00

An appointment form can show the two choices with their offsets and ask the user to choose. For an unattended recurring task, decide whether it runs once, which occurrence it uses, or whether running twice is intentional. Save that decision.

A missing time has no valid occurrence

During New York’s spring transition on 8 March 2026, the clock moves from 2am to 3am. A requested local time of 2:30am does not occur.

Attaching ZoneInfo does not turn the constructor into a validator. Python accepts the following value; converting it to UTC and back exposes the mismatch:

missing = datetime(2026, 3, 8, 2, 30, tzinfo=ny)
returned = missing.astimezone(utc).astimezone(ny)

print(missing.isoformat())
print(returned.isoformat())

Output:

2026-03-08T02:30:00-05:00
2026-03-08T03:30:00-04:00

PEP 495 explicitly notes that constructors do not check for these missing times. The round trip illustrates the problem; it is not a complete input-validation routine.

For an interactive booking, a clear response is to reject that clock time and offer valid alternatives. For an unattended schedule, the product might skip the occurrence, run at the first valid time, or shift it forward by the gap. Those last two choices are different: in this example they could mean 3am and 3:30am respectively.

Check the scheduling library’s documented behaviour and test your chosen rule. Do not assume every library resolves gaps and overlaps identically, or that every transition in the world is one hour long.

Treat time-zone data as a maintained dependency

Even a correctly modelled schedule depends on the rules it uses. IANA updates its database as governments change offsets, boundaries and daylight-saving arrangements. A future conversion is based on the rules available when it was calculated.

That creates an operational question: if a future appointment promises 9am local time and the rules change, should its already computed UTC instant move?

For that promise, it may need to. Keep the definition so you can recompute affected future occurrences. For an event explicitly fixed to a UTC instant, preserve that instant. W3C’s work-in-progress guidance on future and recurring events discusses why future local-time intent and previously calculated instants need separate attention.

Make updates part of the application’s normal release process:

  • Know whether the running application uses operating-system data, a package or another bundled database.
  • Deploy the intended data and check representative conversions in the actual runtime.
  • Recompute affected future occurrences according to the product’s rules, while preserving completed execution records.

Long-running processes need attention too. PEP 615 specifies that existing ZoneInfo objects retain their behaviour when the underlying files change. Updating a package on disk does not prove that a worker already in memory has adopted it. A planned restart during deployment is often easier to reason about than changing caches inside a running scheduler.

Test the promise before shipping the schedule

An ordinary weekday is a weak test of date handling. Build tests around the boundaries that could change what a user experiences:

Check

What a useful test establishes

Local-time recurrence across a clock change

The chosen clock time stays fixed while the UTC execution time changes as required.

An elapsed interval across the same change

The elapsed interval stays fixed, even if the local clock reading changes.

A repeated local time

The saved policy selects the intended occurrence, or explicitly selects both.

A missing local time

Validation or recurrence expansion applies the documented gap policy.

A different viewer or server time zone

Presentation and deployment location do not silently change the governing schedule.

A time-zone database update

Future occurrences are reconsidered where required; completed records stay intact.

Also test the handoff to the job runner. Finding the correct instant does not by itself prevent a retried dispatch from creating duplicate work. Give each intended occurrence a stable identity and use idempotent retry handling where the execution interface supports it.

A useful acceptance test starts with a sentence a user could recognise: “Send this report at 9am New York time, including after the clocks change.” Save it, reload it, generate the next occurrences, and inspect both their local and UTC values. That checks whether the application preserved the promise through storage and occurrence generation. Test dispatch and retries separately.

Sources and verification

Sources checked on 30 October 2026. The Python examples were executed on Python 3.12.14 with IANA data 2026e and checked against NIST’s 2026 US transition dates. They demonstrate date arithmetic and local-time resolution, not a production scheduler.

  1. IANA: Time Zone Database.
  2. IANA: Theory and pragmatics of the tz code and data.
  3. NIST: Daylight Saving Time Rules.
  4. Python 3.12: zoneinfo — IANA time zone support.
  5. Python 3.12: datetime operations.
  6. PEP 495: Local Time Disambiguation.
  7. PEP 615: IANA time-zone support and data updates.
  8. PostgreSQL: Date/Time Types.
  9. RFC 5545, section 3.8.5.3: Recurrence Rule.
  10. Google Calendar API: calendars, events and time zones.
  11. W3C: Working with Time and Timezones — Group Draft Note dated 26 July 2025; a work in progress.