How-to
Automating Reporting WorkflowsDeep dive

Schedule Reports Correctly Across Time Zones and DST

Keep an 08:00 Excel report at 08:00 local time all year: why a UTC cron entry drifts by an hour when daylight saving starts and ends, time-zone-aware schedules in systemd, cronie, Kubernetes and Python, the skipped and repeated hours to avoid, a guard that works on UTC-only schedulers, and report periods that are 23 or 25 hours long.

The sales report is meant to land at 08:00 London time. It is scheduled with 0 7 * * * on a server that runs in UTC, and all summer it arrives at 08:00. Then the clocks go back at the end of October, and from the next morning it arrives at 07:00 — before the overnight data load has finished, so it reports a short day. Nobody changed anything; the schedule simply runs in UTC while the people reading the report live by local time. This page shows how the drift happens, how to give each common scheduler a time zone, which local times to avoid on the days the clocks change, a guard pattern for schedulers that only speak UTC, and how to compute report periods correctly when a day is 23 or 25 hours long. It belongs to Scheduling Python Excel Scripts with Cron.

Prerequisites

Bash
pip install croniter pandas

The examples use Python's built-in zoneinfo for time zones and croniter to compute when a cron expression fires. On Windows, also pip install tzdata, because Windows does not ship the IANA time zone database that zoneinfo reads.

Step 1 — See the drift

Compute when a UTC cron entry fires around the October clock change, and what time that is in London:

Python
from datetime import datetime
from zoneinfo import ZoneInfo
from croniter import croniter

UTC, LONDON = ZoneInfo("UTC"), ZoneInfo("Europe/London")

runs = croniter("0 7 * * *", datetime(2026, 10, 22, 12, tzinfo=UTC))
for _ in range(6):
    t = runs.get_next(datetime)
    print(f"{t:%a %d %b %H:%M} UTC  →  {t.astimezone(LONDON):%H:%M %Z} in London")

Until Sunday 25 October the 07:00 UTC run is 08:00 British Summer Time; from that day on it is 07:00 Greenwich Mean Time. In March the opposite happens and a 08:00 GMT schedule moves to 09:00. The fix is not to edit the cron line twice a year — someone will forget, and the change has to happen at exactly the right moment — but to tell the scheduler which time zone the schedule means.

A 07:00 UTC schedule seen from London Before the clocks go back on 25 October 2026, a 07:00 UTC run lands at 08:00 BST. From 25 October it lands at 07:00 GMT, an hour earlier for the readers, although the cron line never changed. Fri 23 Oct 08:00 BST Sat 24 Oct 08:00 BST Sun 25 Oct 07:00 GMT · clocks back Mon 26 Oct 07:00 GMT Tue 27 Oct 07:00 GMT

Step 2 — Give the scheduler a time zone

Most schedulers can interpret a schedule in a named time zone. Use the IANA name such as Europe/London or America/New_York — never a fixed offset like +01:00, which does not follow daylight saving:

  • systemd timers accept a time zone at the end of OnCalendar: OnCalendar=Mon..Fri 08:00 Europe/London. Check it with systemd-analyze calendar "Mon..Fri 08:00 Europe/London", which prints the next run in local and UTC time. See Schedule an Excel Report with systemd Timers.
  • cronie, the cron of Fedora, RHEL and many other distributions, supports a CRON_TZ=Europe/London line in the crontab that applies to the entries below it. Debian and Ubuntu's classic cron does not, and silently ignores it — check man 5 crontab on your server.
  • Kubernetes CronJobs take spec.timeZone: "Europe/London" (stable since Kubernetes 1.27).
  • APScheduler and Celery beat take a time zone on the trigger or the app configuration.
  • GitHub Actions schedule triggers run in UTC only; use the guard in Step 4.

In Python, the same computation croniter did above becomes time-zone-aware simply by starting from a local time:

Python
runs = croniter("0 8 * * *", datetime(2026, 10, 22, 12, tzinfo=LONDON))
for _ in range(6):
    t = runs.get_next(datetime)
    print(f"{t:%a %d %b %H:%M %Z}  =  {t.astimezone(UTC):%H:%M} UTC")

Now the run stays at 08:00 local time through the change, and it is the UTC time that moves from 07:00 to 08:00. That is what a scheduler configured with a time zone does internally.

Step 3 — Avoid the skipped and repeated hours

On the two change days, part of the night behaves strangely. In spring, local time jumps from 01:00 to 02:00 in London — times in between never happen. In autumn, 01:00 to 02:00 happens twice. Python shows both:

Python
from datetime import timedelta

gap = datetime(2026, 3, 29, 1, 30, tzinfo=LONDON)          # does not exist
print("01:30 on 29 Mar  →", gap.astimezone(UTC).astimezone(LONDON).strftime("%H:%M %Z"), "after a round trip")

first = datetime(2026, 10, 25, 1, 30, tzinfo=LONDON, fold=0)
second = datetime(2026, 10, 25, 1, 30, tzinfo=LONDON, fold=1)
print("01:30 on 25 Oct  →", first.astimezone(UTC).strftime("%H:%M UTC"), "and", second.astimezone(UTC).strftime("%H:%M UTC"))
print("hours between them:", (second.astimezone(UTC) - first.astimezone(UTC)) / timedelta(hours=1))

A local 01:30 on the spring-forward day does not exist; Python resolves it to 02:30 BST. On the autumn day, 01:30 exists twice, an hour apart — fold chooses which. Schedulers handle these cases differently: some skip a job in the gap, some run it late, and some run a job in the overlap twice. The simple, portable rule is to schedule nothing between 01:00 and 03:00 local time in zones that observe daylight saving. Most zones change in that window — the exact hour differs, so check yours. An 08:00 report is never affected; a nightly 02:00 job is.

The two awkward nights of the year On the spring change, local times between 01:00 and 02:00 in London do not exist and a job scheduled then may be skipped or moved. On the autumn change, 01:00 to 02:00 happens twice and a job may run twice. Scheduling outside 01:00 to 03:00 avoids both. spring forward (29 Mar 2026) 01:00 → 02:00 skipped 01:30 never happens job may be skipped or late fall back (25 Oct 2026) 01:00 – 02:00 happens twice 01:30 occurs twice job may run twice the safe rule nothing 01:00 – 03:00 local 08:00 reports unaffected check your zone's hour

Step 4 — A guard for UTC-only schedulers

When the scheduler can only do UTC — GitHub Actions, some hosted cron services, Debian's cron — schedule the job at both UTC times the local time can map to, and let the script decide whether this is the right one:

Python
from pathlib import Path

def should_run(now_utc, local_hour=8, tz=LONDON, marker_dir=Path("markers")):
    local = now_utc.astimezone(tz)
    if local.hour != local_hour:
        return False, f"{local:%H:%M %Z} is not {local_hour:02d}:00 local"
    marker = marker_dir / f"report_{local:%Y-%m-%d}.done"
    if marker.exists():
        return False, "already ran today"
    marker_dir.mkdir(exist_ok=True)
    marker.touch()
    return True, f"running at {local:%H:%M %Z}"

# The crontab has: 0 7,8 * * *  (both UTC hours that can be 08:00 in London)
for ts in ["2026-10-24 07:00", "2026-10-24 08:00", "2026-10-26 07:00", "2026-10-26 08:00"]:
    now = datetime.fromisoformat(ts).replace(tzinfo=UTC)
    print(ts, "UTC →", should_run(now))

In summer the 07:00 UTC run is 08:00 BST and goes ahead, and the 08:00 UTC run is 09:00 and exits immediately; after the change it is the other way round. The marker file guards against running twice on the same local date if a scheduler retries. In CI, where no file survives between runs, check whether today's report already exists in its destination instead — see Run a Python Excel Report in GitHub Actions. Passing now_utc in, rather than calling datetime.now() inside, is what makes the function testable on any date.

The guard at each UTC run The job is scheduled at 07:00 and 08:00 UTC. At each run the script converts the time to London time. If it is not 08:00 local, it exits. If today's marker exists, it exits. Otherwise it writes the marker and runs the report. Converted to local time… not 08:00 local exit quietly the other UTC run is the one 08:00, marker exists exit already ran today 08:00, no marker run the report write today's marker

Step 5 — Compute report periods in local time

A daily report covers "yesterday" — but yesterday in London starts at midnight London time, and on the change days it is not 24 hours long. Build the period in local time and convert to UTC only to query the database:

Python
import pandas as pd

def day_bounds(local_day, tz=LONDON):
    start = datetime.combine(local_day, datetime.min.time(), tzinfo=tz)
    end = datetime.combine(local_day + timedelta(days=1), datetime.min.time(), tzinfo=tz)
    return start.astimezone(UTC), end.astimezone(UTC)

for d in ["2026-03-29", "2026-06-15", "2026-10-25"]:
    s, e = day_bounds(datetime.fromisoformat(d).date())
    print(d, f"{s:%d %b %H:%M} → {e:%d %b %H:%M} UTC", f"({(e - s) / timedelta(hours=1):.0f} h)")

hours = pd.date_range("2026-10-25", periods=25, freq="h", tz="Europe/London")
print(len(hours), "hourly rows on 25 Oct; 01:00 appears", (hours.hour == 1).sum(), "times")

29 March is a 23-hour day and 25 October a 25-hour one. A report that assumes 24 hourly rows will be missing an hour in October or invent one in March, and one that queries 00:00–24:00 UTC will be an hour off in summer. Computing the bounds from local midnights gets every day right. When writing timestamps to Excel, which has no notion of time zones, convert them to local time, drop the zone, and say which zone in the header — as in Fix "Excel Does Not Support Timezones in Datetimes".

Common pitfalls

  • Fixed offsets instead of zone names. +01:00 never changes; Europe/London does.
  • CRON_TZ on a cron that ignores it. Debian and Ubuntu cron do not support it; verify.
  • Jobs between 01:00 and 03:00. They are skipped or doubled on change days.
  • Assuming 24 hours in a day. Two days a year are 23 and 25 hours long.
  • datetime.now() buried in the logic. Pass the time in so DST cases can be tested.

Conclusion

A schedule runs in whatever time zone the scheduler uses, so a UTC entry drifts by an hour for local readers twice a year. Give the scheduler a named IANA zone — systemd OnCalendar, cronie CRON_TZ, Kubernetes timeZone, or a zone-aware trigger in Python — keep jobs out of the 01:00–03:00 window, and on UTC-only schedulers run at both possible UTC hours behind a local-time guard. Compute report periods from local midnights so 23- and 25-hour days come out right.

Frequently asked questions

Why does my cron report run an hour early or late after the clocks change? The cron entry is interpreted in the server's time zone, usually UTC, which does not observe daylight saving. The local time it corresponds to moves by an hour when daylight saving starts or ends.

How do I run a systemd timer in a specific time zone? Put the zone name at the end of OnCalendar, for example OnCalendar=Mon..Fri 08:00 Europe/London, and check it with systemd-analyze calendar.

What time should I avoid scheduling jobs because of DST? Avoid 01:00 to 03:00 local time in zones with daylight saving. On change days those times are skipped or repeated, so a job may not run or may run twice.

How do I schedule a local-time report on GitHub Actions? Its schedules are UTC only. Schedule both UTC hours that can match the local time and have the script exit unless the current local hour is the target and the report has not already run that day.

More on scheduling & orchestration

More on scheduling & orchestration →