Timezone-Safe Job Scheduling Across DST
Twice a year, every recurring job scheduled in local time meets an hour that does not exist or an hour that happens twice. This guide makes scheduled jobs behave predictably across daylight-saving transitions, as part of Scheduled & Delayed Jobs in Queue Fundamentals & Architecture.
Problem Statement
A payroll platform runs a nightly "close the day" job at 02:30 in each customer's local time. On the March spring-forward night in Europe, 02:30 did not exist; the scheduler skipped the run and 1,800 European customers had no daily close. On the October fall-back night, 02:30 happened twice; the job ran twice for the same day and double-posted accruals. Meanwhile, a separate job scheduled "every 24 hours" drifted from 02:00 to 03:00 local time in summer. You want every schedule to state explicitly whether it follows the clock on the wall or elapsed time, a defined behaviour for skipped and repeated hours, per-customer time zones, and tests that prove the next transitions are handled.
Prerequisites
- Python 3.9+ (
zoneinfo) or an equivalent tz library (java.time, Gotime.LoadLocation, Luxon). - An IANA time zone name stored per customer (
Europe/Berlin, notCETor+01:00). - A scheduler you control: Celery beat, a cron-like scheduler table, or a delayed-job queue.
- Idempotent jobs keyed by the business date they process.
Step 1 — Decide Whether a Schedule Is Wall-Clock or Elapsed
Every recurring schedule is one of two kinds, and mixing them up causes most DST bugs.
- Elapsed-time schedules ("every 15 minutes", "every 24 hours"): intervals in real time. Define them in UTC; they never meet DST.
- Wall-clock schedules ("02:30 in Berlin", "9 a.m. on the customer's Monday"): tied to local time. They must be computed in the local zone and converted to UTC for each run.
# schedules.yaml — the kind is explicit for every entry
- name: refresh-exchange-rates
kind: elapsed
every: 15m # UTC intervals, unaffected by DST
- name: close-the-day
kind: wall_clock
local_time: "02:30"
timezone: per_customer # each customer's IANA zone
on_nonexistent: shift_forward # spring-forward: run at 03:00 local
on_ambiguous: first # fall-back: run once, at the first 02:30
The "every 24 hours" drift in the problem statement is an elapsed schedule used where a wall-clock one was meant: 24 hours after 02:00 on the last winter night is 03:00 local the next day.
Step 2 — Compute the Next Run in the Local Zone, Then Convert to UTC
Store and compare instants in UTC, but compute wall-clock schedules in the customer's zone. zoneinfo resolves the zone's rules, including historical and future changes.
from datetime import datetime, timedelta, time
from zoneinfo import ZoneInfo
UTC = ZoneInfo("UTC")
def next_run_utc(after_utc: datetime, local: time, tz_name: str) -> datetime:
tz = ZoneInfo(tz_name)
local_after = after_utc.astimezone(tz)
day = local_after.date()
while True:
candidate = resolve_local(datetime.combine(day, local), tz) # Step 3 decides fold/gaps
if candidate.astimezone(UTC) > after_utc:
return candidate.astimezone(UTC)
day += timedelta(days=1)
Iterating by local calendar day is the essential move: "the next 02:30" is a calendar question in local time, not "24 hours later".
Step 3 — Define Behaviour for Skipped and Repeated Times
datetime has a fold attribute to pick between the two occurrences of an ambiguous time; non-existent times need explicit handling because zoneinfo will silently produce a time that round-trips differently.
def resolve_local(naive: datetime, tz: ZoneInfo, on_nonexistent="shift_forward", on_ambiguous="first"):
first = naive.replace(tzinfo=tz, fold=0)
second = naive.replace(tzinfo=tz, fold=1)
# Non-existent: converting to UTC and back does not reproduce the wall time
if first.astimezone(UTC).astimezone(tz).replace(tzinfo=None) != naive:
if on_nonexistent == "skip":
return None
# shift forward: the UTC round trip lands on the equivalent instant after the gap
return first.astimezone(UTC).astimezone(tz) # 02:30 -> 03:30 for a +1h gap
# Ambiguous: fold=0 and fold=1 map to different UTC instants
if first.utcoffset() != second.utcoffset():
return first if on_ambiguous == "first" else second
return first
For "close the day", shift forward runs the job at the equivalent instant after the gap (03:30 local on spring-forward), and first runs it once in October at the summer-time 02:30. Both policies are defensible; the point is that the policy is chosen and written down, not left to the library's default. Because the job processes a business date, making it idempotent by that date (INSERT ... ON CONFLICT (customer_id, business_date) DO NOTHING) is the second line of defence against a double run.
Step 4 — Schedule Per-Customer Local Times Efficiently
Thousands of customers in dozens of zones should not mean thousands of cron entries. Group customers by zone, compute one next-run instant per zone, and fan out when it fires.
# scheduler tick every minute (a single leader; see leader election guide)
def tick(now_utc: datetime) -> None:
for tz_name in active_timezones(): # ~40 zones, not 20,000 customers
due_at = next_run_utc(last_run_utc(tz_name) or now_utc - timedelta(days=1),
time(2, 30), tz_name)
if due_at <= now_utc:
business_date = due_at.astimezone(ZoneInfo(tz_name)).date() - timedelta(days=1)
for batch in customers_in_zone(tz_name, batch_size=500):
close_the_day.delay([c.id for c in batch], business_date.isoformat())
record_last_run(tz_name, due_at)
Recording the last run per zone makes the tick restart-safe: after a scheduler outage it catches up on missed runs rather than skipping them. Only one scheduler instance must run this loop — preventing duplicate scheduled jobs with leader election covers how. The business date is passed explicitly, so the job never infers "yesterday" from the time it happens to run.
Step 5 — Configure Framework Schedulers Deliberately
Framework schedulers have their own time zone behaviour; check it rather than assuming.
# Celery beat: run wall-clock schedules in a named zone
app.conf.timezone = "Europe/Berlin" # crontab() entries are interpreted in this zone
app.conf.enable_utc = True # internal timestamps stay UTC
app.conf.beat_schedule = {
"close-day-berlin": {"task": "payroll.close_the_day_zone",
"schedule": crontab(hour=2, minute=30),
"args": ("Europe/Berlin",)},
}
Celery beat, Sidekiq-cron, BullMQ job schedulers, Kubernetes CronJobs (timeZone field), and Solid Queue recurring tasks each have documented DST behaviour — some skip non-existent times, some run twice on ambiguous ones. For customer-facing correctness, compute next runs yourself (Steps 2–4) and use the framework only to enqueue at explicit UTC instants. Celery-specific setup is in cron-style scheduling with Celery beat.
Step 6 — Test the Next Transitions Explicitly
DST bugs appear twice a year, which is exactly why they should be caught by tests that run every day. Enumerate transition dates for the zones you support and assert behaviour around each.
import pytest
from datetime import date
TRANSITIONS = [
("Europe/Berlin", date(2027, 3, 28)), ("Europe/Berlin", date(2026, 10, 25)),
("America/New_York", date(2027, 3, 14)), ("America/New_York", date(2026, 11, 1)),
("Australia/Sydney", date(2026, 10, 4)), ("Australia/Sydney", date(2027, 4, 4)),
]
@pytest.mark.parametrize("tz,day", TRANSITIONS)
def test_one_run_per_local_day_across_transition(tz, day):
start = datetime.combine(day - timedelta(days=2), time(12), ZoneInfo(tz)).astimezone(UTC)
runs, t = [], start
for _ in range(5):
t = next_run_utc(t, time(2, 30), tz)
runs.append(t.astimezone(ZoneInfo(tz)).date())
assert runs == sorted(set(runs)) and len(runs) == 5 # one run per local day, no gaps
Include a southern-hemisphere zone (transitions in opposite months) and, if you serve them, zones with 30-minute offsets or no DST. Keep the tz database current: tzdata updates change future rules.
Verification
Before each transition, list the UTC instants the scheduler will use for the transition day in each active zone and check them against expectations; afterwards, assert that every customer has exactly one daily-close record for the transition business date.
SELECT business_date, count(*) AS customers, count(DISTINCT customer_id) AS distinct_customers
FROM day_closes WHERE business_date IN ('2026-10-24', '2026-10-25') GROUP BY 1;
-- customers must equal distinct_customers, and equal the number of active customers
Gotchas & Edge Cases
Fixed offsets instead of zones. Storing +01:00 loses DST rules entirely. Store IANA names.
Server local time. Never let the scheduler depend on the host's time zone; containers and hosts differ. Set everything explicitly.
Zone rule changes. Governments change DST rules with little notice. Update tzdata regularly and recompute future schedules after updates.
Delays across transitions. "Run in 24 hours" is elapsed time; "run tomorrow at the same local time" is wall-clock. Pick the right one for reminders and follow-ups.
FAQ
Should all schedules just use UTC? Machine-facing jobs, yes. Human-facing ones (reports at 9 a.m. local, end-of-business-day processing) must follow local time, or they drift by an hour for half the year.
Is skipping the non-existent hour ever right? For jobs that are meaningless if late (a "wake-up at 02:30" notification), skipping may be correct. For jobs that must happen once per day, shift forward.
How should the job decide which business day it covers? Pass it in. A job that computes "yesterday" from its own start time gets the wrong answer when it runs late, runs after midnight in a different zone than expected, or is retried the next morning. The scheduler knows which local day it is closing; put that date in the payload and make the job's idempotency key include it.
What about leap seconds? Job schedulers can ignore them; operating systems smear or step the clock, and job timing tolerances are far larger.
Related
- Scheduled & Delayed Jobs — scheduling mechanisms and trade-offs.
- Cron-Style Scheduling with Celery Beat — beat configuration.
- BullMQ Job Schedulers for Repeatable Jobs — time zones in BullMQ.
- Preventing Duplicate Scheduled Jobs with Leader Election — one scheduler, one run.