Time Zones and DST (The Clocks Change, and the Alarm Still Rings Once)
The Alarm Clock on Clock-Change Night
Twice a year the clocks change. In spring they jump forward an hour, so in London the time goes 00:59, then 02:00: half past one never happens. In autumn they go back an hour, so 01:00 to 01:59 happens twice.
Imagine your alarm is set for 01:30. What should it do?
- Spring (a gap): 01:30 is skipped. The alarm rings at the first real moment after the gap: the instant the clocks jumped (02:00 local).
- Autumn (a fold): 01:30 happens twice. The alarm rings the first time only.
Either way, the alarm rings exactly once. PyScheduler reads each schedule's times in the job's own time zone, then turns them into UTC instants straight away, so nothing else in the scheduler has to worry about clocks changing.
Four Kinds of Schedule
| Schedule | Fires |
|---|---|
CronSchedule |
at a cron expression's times on the days it matches |
AtSchedule |
at fixed local times every day |
EverySchedule |
every step of real time between a start and an end (end included) |
BusinessDaySchedule |
at fixed times on the n-th business day of the month (-1 is the last) |
Each one can also be limited by days (weekday numbers, 0 is Monday) and
months (1 to 12).
Every 15 Minutes Means Every 15 Real Minutes
An every schedule counts real time, like a stopwatch, not wall-clock
slots. On the night the clocks go back, "every 15 minutes between 00:00
and 03:00" in London fires 17 times: the 13 wall-clock slots plus 4
more for the hour that happens twice.
A window may cross midnight, like 22:00 to 02:00. All its firings belong to the day the window opened, and the next day's window opens at 22:00 the next evening.
Firings and Run Dates
A firing is one ring of the alarm: a UTC instant and the run date
it is for. The job's business calendar decides which days fire at all.
With run_date: previous_business_day, Monday's run is about Friday's
work, just like homework handed in on Monday is the work set on Friday.
If two schedules land on the same instant, the job fires only once.
This holds even across days: if last night's 22:00-02:00 window and
today's 01:00 alarm both want 01:00, firings_between and next_firing
ring once, with the earlier day's run date. firings_on shows one
landing day at a time, so it can list an instant that the previous day's
window also produced. Use the stream for the deduplicated list.
Try It
from dataclasses import dataclass
from datetime import UTC, date, datetime, time
from zoneinfo import ZoneInfo
from pyscheduler.calendars import ALL_DAYS
from pyscheduler.schedule.cron import CronExpr
from pyscheduler.schedule.firings import RunDateRule, firings_on, next_firing, resolve_local
from pyscheduler.schedule.specs import CronSchedule, Schedule
london = ZoneInfo("Europe/London")
print(resolve_local(date(2027, 3, 28), time(1, 30), london)) # gap: 01:00 UTC
print(resolve_local(date(2026, 10, 25), time(1, 30), london)) # fold: 00:30 UTC
@dataclass(frozen=True)
class Job:
schedules: tuple[Schedule, ...]
timezone: ZoneInfo
run_date_rule: RunDateRule = RunDateRule.SAME_DAY
job = Job((CronSchedule(CronExpr.parse("30 1 * * *")),), london)
print(firings_on(job, date(2026, 10, 25), ALL_DAYS)) # one firing, not two
print(next_firing(job, ALL_DAYS, datetime(2026, 10, 25, 1, tzinfo=UTC)))
firings_between(job, calendar, start, end) lists every firing after
start and up to end, one at a time. next_firing gives up after
four years (1464 days) and returns None, which is how PyScheduler knows
a schedule like 0 0 30 2 * (30 February) never fires.