Cron Daylight Saving Time: Skipped and Doubled Runs
Contents
Daylight saving time only affects jobs scheduled inside the hour the clocks skip or repeat, usually between 01:00 and 03:00 local time, and what happens to them depends on the scheduler. cronie and Debian's cron protect jobs in the system zone: a fixed-time job in the skipped hour runs right after the change, and one in the repeated hour runs once. BusyBox crond, cronie's CRON_TZ entries, and Kubernetes CronJobs with a time zone skip the missing run and run the repeated one twice. A server and scheduler on UTC have neither problem.
The behavior below comes from each scheduler's source code or official documentation, checked October 24, 2026: cronie 1.7.2, Debian's cron 3.0pl1-197, BusyBox 1.37.0, systemd 262, Kubernetes 1.37, Quartz.NET 3.22.7 and 4.6.0, Hangfire 1.8.25, and Celery 5.6.3. How cron picks its time zone in the first place is covered in cron timezone.
What Happens to the Clock on a Transition Night#
In the US, clocks go back at 02:00 on the first Sunday in November and forward at 02:00 on the second Sunday in March (tz database). The UK follows the EU rule: both changes happen at 01:00 UTC on the last Sunday of March and October (tz database). The next four:
| Zone | Date | Local clock | Effect | UTC instant |
|---|---|---|---|---|
| Europe/London | Sunday, October 25, 2026 | 01:59:59 BST → 01:00:00 GMT | 01:00-01:59 happens twice | 01:00 |
| America/New_York | Sunday, November 1, 2026 | 01:59:59 EDT → 01:00:00 EST | 01:00-01:59 happens twice | 06:00 |
| America/New_York | Sunday, March 14, 2027 | 01:59:59 EST → 03:00:00 EDT | 02:00-02:59 never happens | 07:00 |
| Europe/London | Sunday, March 28, 2027 | 00:59:59 GMT → 02:00:00 BST | 01:00-01:59 never happens | 01:00 |
So in New York, 30 1 * * * has two candidate instants on November 1 (05:30 and 06:30 UTC) and 30 2 * * * has none on March 14. In London both problems fall in the 01:00 hour, so a 02:30 job is safe there and unsafe in New York. Between October 25 and November 1, London is four hours ahead of New York instead of five, which moves any job meant to line up with the other city.
How Each Scheduler Handles the Skipped and Repeated Hour#
Schedulers treat two kinds of schedule differently. A fixed-time schedule names a time of day, such as 30 1 * * *. An interval schedule repeats through the day, such as */15 * * * * or hourly 0 * * * * (see cron expression syntax). The goal Vixie-derived crons and Cronos describe is that a fixed-time job runs once per date and an interval job keeps its real-time spacing. Interval schedules mostly carry on through the skipped hour, so the table covers fixed times there and both kinds in the repeated hour. Times are for New York:
| Scheduler | Fixed time in skipped hour | Fixed time in repeated hour | Interval in repeated hour |
|---|---|---|---|
| cronie 1.7.2, system zone | Runs right after the jump | Runs once | Runs in both passes |
cronie 1.7.2, CRON_TZ entry |
Doesn't run | Runs twice | Runs in both passes |
| Debian and Ubuntu cron | Runs right after the jump | Runs once | Runs in both passes |
| BusyBox crond 1.37.0 | Doesn't run | Runs twice | Runs in both passes |
systemd 262 OnCalendar |
Doesn't run | Runs once | Doesn't run at all |
Kubernetes 1.37 .spec.timeZone |
Doesn't run | Runs twice | Runs in both passes |
GitHub Actions timezone |
Runs at 03:00 | Not documented | Not documented |
| EventBridge Scheduler | Doesn't run | Runs once | Not documented |
| Google Cloud Scheduler | Not documented | "Execution anomalies" | Not documented |
| Quartz.NET 3.22.7 | Runs at 03:30 | Runs once | Second pass skipped |
| Quartz.NET 4.6.0 | Runs at 03:00 | Runs once | Runs in both passes |
| Hangfire 1.8.25 (Cronos 0.11.1) | Runs at 03:00 | Runs once | Runs in both passes |
| Celery beat 5.6.3 | Not documented | Not documented | Hourly crontab skips a run (bug) |
cronie: The Source Settles Which Man Page Is Right#
cronie's two man pages contradict each other. crontab(5) says times missing during the daylight saving change "will never match" and repeated times "will cause matching jobs to be run twice". cron(8) says that after a change of less than three hours, jobs in the skipped interval "will be run immediately" and "running the same job twice is avoided".
The 1.7.2 source decides, and each page is right about different jobs. Every minute, cron.c compares the system's local clock with the minute it expected next. A jump forward of 6 to 180 minutes runs the current minute's interval jobs, then every fixed-time job for each skipped minute. A jump backward of up to 180 minutes runs only interval jobs until the clock catches up. Bigger jumps count as clock corrections. So for entries in the daemon's own zone, cron(8) is right.
CRON_TZ entries are different. cronie converts each real minute to the entry's zone and matches it once (cron.c). On a UTC server, the system clock never jumps, so none of the special cases apply, and crontab(5) is right: the skipped hour never matches and the repeated hour matches twice. A comment in the same function warns that jobs in a job-specific zone can be "missed or run twice" and recommends not scheduling them in the hour of the change.
What counts as an interval job is narrower than cron(8)'s "granularity greater than one hour" suggests: an entry whose minute or hour field starts with * (entry.c). 0 */2 * * * is an interval job, so its 02:00 run is dropped on the spring-forward night, while 0 0-23/2 * * *, which means the same thing, is fixed-time and runs at 03:00.
Debian and Ubuntu#
Debian's cron 3.0pl1-197 (Debian 13) has the same logic (cron.c), and so does Ubuntu 24.04's 3.0pl1-184ubuntu2 (cron.c). Its cron(8) describes it accurately: only jobs "not specified as @hourly, nor with '*' in the hour or minute specifier" are caught up or protected from repeats. Debian has no CRON_TZ, so the protection covers every fixed-time entry.
BusyBox crond#
BusyBox wakes up on the epoch clock, which daylight saving never moves, and matches each minute's local time with no special case (crond.c). A job in the skipped hour doesn't run; one in the repeated hour runs twice, unless its first run is still going, because BusyBox won't start a line whose previous process is alive. BusyBox crond is the cron in Alpine and many minimal container images.
systemd Timers#
A calendar timer searches forward through wall-clock fields from its last trigger (calendarspec.c), in the zone named in OnCalendar= or the system zone. A time in the skipped hour doesn't exist, so that day is skipped; systemd#5595 is a daily 02:00 timer that didn't run on the spring-forward day. systemd's own tests check that a time in the repeated hour "happens only once" (test-calendarspec.c).
The same search steps over the repeated hour's second pass, so minutely, hourly, or *:0/15 timers don't fire at all for that hour. systemd#8647 reports it, and a maintainer agreed it's a bug; it was still open on October 24, 2026. For interval work, use a monotonic timer, which systemd.timer(5) describes as "independent of wall-clock time and timezones":
# /etc/systemd/system/queue-sweep.timer, paired with queue-sweep.service
[Unit]
Description=Sweep the retry queue every 15 minutes of real time
[Timer]
OnBootSec=5min
OnUnitActiveSec=15min
[Install]
WantedBy=timers.targetKubernetes CronJob#
With .spec.timeZone set, Kubernetes 1.37 prefixes the schedule with TZ=<zone> (cronjob_controllerv2.go) and computes run times with robfig/cron v3.0.1. That library walks forward an hour at a time in real time (spec.go). On March 14, 2027, hour 02 never comes up, so 30 2 * * * next runs on March 15. On November 1, 2026, hour 01 comes up twice, so 30 1 * * * runs at 05:30 and 06:30 UTC. Each Job's name includes its scheduled time in epoch minutes (utils.go), so the second run creates a new Job. kubernetes#121594 reported a 0 2 * * * Europe/Berlin CronJob running twice; the triage bot closed it as not planned.
concurrencyPolicy: Forbid prevents the second run only if the first Job is still running. Without .spec.timeZone, the schedule follows the kube-controller-manager's local zone (CronJob time zones). The Kubernetes CronJob monitoring guide covers the rest of the spec.
GitHub Actions, EventBridge Scheduler, and Cloud Scheduler#
- GitHub Actions runs schedules in UTC unless you set
timezone. In a zone with daylight saving, "scheduled workflows in skipped hours advance to the next valid time", so 2:30 AM becomes 3:00 AM (schedule event). The page says nothing about the repeated hour, and warns that under high load "some queued jobs may be dropped". - EventBridge Scheduler documents both cases: an invocation at a time that doesn't exist "is skipped", and in the fall the schedule "runs only once and does not repeat its invocation" (daylight saving time).
rate(1 days)means 24 hours of real time, so its local time moves an hour at each change. - Google Cloud Scheduler defaults to UTC. Its docs say only that daylight saving "can cause jobs to run or not run unexpectedly", that a job at a repeated time "might observe execution anomalies", and that "UTC is recommended" (time zone).
Quartz.NET, Hangfire, and Celery Beat#
Quartz.NET pins its daylight saving behavior in unit tests. In 3.22.7, a daily 02:30 job in New York fires at 03:30 EDT on the spring-forward day, a repeated fixed time fires only at its first occurrence, and an interval expression skips the repeated hour's second pass, which the test marks as a known deviation (CronExpressionDstTests.cs). 4.6.0 fires the gap case at 03:00 and runs interval expressions in both passes.
Hangfire 1.8.25 uses Cronos 0.11.1, which moves a skipped time to the next valid time, runs a daily expression once, and runs interval expressions in both passes (Cronos README). Cronos and Quartz.NET 4 also count a range in the time fields as an interval, so a job at minute 30 of hours 1-3 runs at both 01:30s there, where cronie runs it once. Hangfire cron time zones has the full transition table.
Celery beat uses UTC unless you set the app-wide timezone, and Django projects inherit TIME_ZONE (periodic tasks). Its docs don't describe daylight saving. celery#10107, filed against 5.6.2 and still open on October 24, 2026, shows an hourly crontab skipping the second 01:00 at fall-back. 5.6.3 made no change there.
Schedule Local-Time Jobs So the Change Can't Hurt Them#
Run servers and schedulers in UTC. UTC has no transitions, so nothing in the table above applies. Set the system zone to Etc/UTC, use timeZone: Etc/UTC on CronJobs, leave timezone unset in GitHub Actions, and set Celery's timezone = 'UTC'.
Keep local-time jobs out of 01:00 to 03:00 local. That window covers the US, the UK, Central Europe, and Sydney. Other zones change at other hours: Europe/Helsinki skips and repeats 03:00-03:59 under the EU rule, and America/Santiago skips 00:00-00:59 in September and repeats 23:00-23:59 on a Saturday night in April (tz database). Check yours with zdump -V -c 2026,2028 Europe/Helsinki, which lists each transition with its local and UTC times.
Make the job idempotent and record the date it ran. Then a doubled run does nothing, whichever scheduler causes it. This wrapper runs the export at most once per New York calendar day and refuses to overlap a run still in progress:
#!/bin/sh
# /opt/app/nightly-export-once.sh: run nightly-export.sh at most once per New York date.
state=/var/lib/nightly-export
today=$(TZ=America/New_York date +%F)
# A second start while the first is still running exits instead of overlapping it.
exec 9>"$state/lock"
if ! flock -n 9; then
echo "nightly-export: previous run still active, skipping" >&2
exit 0
fi
if [ "$(cat "$state/last-date" 2>/dev/null)" = "$today" ]; then
echo "nightly-export: already ran for $today, skipping" >&2
exit 0
fi
/opt/app/nightly-export.sh || exit $?
echo "$today" > "$state/last-date"The date is written only after a successful run, so a failed run is retried at the next invocation. If the job can run on more than one host, keep the record in the database with a unique key on job and date instead.
Compute interval windows in UTC. An hourly job that processes "the last hour" by local clock sees the same hour twice in November. Track a UTC watermark of the last processed record, or use a real-time interval such as OnUnitActiveSec= or EventBridge rate().
Monitor Local-Time Jobs Across the Change#
Several schedulers above drop a run on a transition night without logging an error. That's the failure a dead man's switch is for: no check-in arrives, so the monitor goes critical when the grace period ends.
CronRadar evaluates every schedule in UTC, so a job that follows local time moves an hour against its monitor twice a year. The schedule syntax rule is to send the earlier of the job's two UTC times, with a grace period of at least an hour plus the job's usual duration. For a report at 10:00 New York time that takes up to 15 minutes, that's 14:00 UTC in summer and 15:00 UTC in winter, so 0 14 * * * with gracePeriod=4500.
There's no CronRadar SDK for Kubernetes and no auto-discovery, so each CronJob reports with its own HTTP check-in. The container reports start, runs the report, reports complete or fail, and exits with the report's own code. Each request gives up after five seconds and ignores errors, so monitoring can't fail the job:
apiVersion: batch/v1
kind: CronJob
metadata:
name: morning-report
spec:
schedule: '0 10 * * *'
timeZone: 'America/New_York' # 14:00 UTC in summer, 15:00 UTC in winter
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: report
image: registry.example.com/morning-report:1.4.2 # needs sh and curl
env:
- name: CRONRADAR_API_KEY
valueFrom:
secretKeyRef:
name: cronradar
key: api-key
- name: RUN_ID
valueFrom:
fieldRef:
fieldPath: metadata.labels['batch.kubernetes.io/job-name']
command:
- /bin/sh
- -c
- |
report() {
event=$1
shift
curl --fail --silent --show-error --max-time 5 --output /dev/null \
--request POST --get --user "$CRONRADAR_API_KEY:" \
--data-urlencode "schedule=0 14 * * *" \
--data-urlencode "gracePeriod=4500" \
--data-urlencode "run_id=$RUN_ID" \
"$@" "https://cron.life/api/v1/ping/morning-report/$event" || true
}
report start
/app/morning-report.sh
rc=$?
if [ "$rc" -eq 0 ]; then
report complete
else
report fail --data-urlencode "message=morning-report.sh exited with code $rc"
fi
exit "$rc"Every check-in carries the schedule, so the first run creates the monitor and a deleted one comes back on the next run. A fail report alerts immediately, without waiting for the grace period.
The rule has costs. In winter, the monitor shows warning for the hour before each run starts. In summer, a report that never starts goes critical at 15:15 UTC, 75 minutes after it was due. A doubled run isn't one of CronRadar's failure modes (missed or late, failed, stuck, disappeared, bad outcome), so it won't alert; the date record above is the guard there. A job scheduled in UTC avoids all of this. For Hangfire or Quartz.NET jobs on a local zone, compare CronRadar's next expected run with the scheduler's next fire time.
Frequently Asked Questions#
Does cron run twice when the clocks go back?#
It depends on the implementation. cronie and Debian's cron run a fixed-time job in the repeated hour once, though interval jobs like */15 keep running through both passes. BusyBox crond, cronie entries under CRON_TZ, and Kubernetes CronJobs with .spec.timeZone run a fixed-time job in the repeated hour twice.
Does cron skip jobs when the clocks go forward?#
cronie and Debian's cron run a fixed-time job from the skipped hour as soon as the clock jumps, at 03:00 in New York. BusyBox, cronie CRON_TZ entries, systemd timers, Kubernetes CronJobs, and EventBridge Scheduler skip that day's run. Hangfire, Quartz.NET, and GitHub Actions run it late instead.
How do Kubernetes CronJobs handle daylight saving time?#
With .spec.timeZone set to a zone that observes daylight saving, a run in the skipped hour doesn't happen that day and a fixed-time run in the repeated hour happens twice, once per pass. Without the field, the schedule follows the kube-controller-manager's zone. timeZone: 'Etc/UTC' removes both problems.
Should cron run in UTC?#
Yes, for the server and the scheduler. UTC never skips or repeats an hour. It's also the default for GitHub Actions, EventBridge Scheduler, and Cloud Scheduler, and the zone CronRadar evaluates every schedule in. When a job must follow local business hours, set the zone on that job alone and keep it outside the transition window.
How do I schedule a job at 2:30 AM local time safely?#
Move it if you can: in New York, 03:30 happens exactly once every day, while 02:30 is missing on the spring-forward date. If it has to be 02:30, make the job idempotent with a date record, and check what your scheduler does on that night in the table above. On cronie or Debian's cron in the system zone, it runs at 03:00 that day.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.