# Cron Daylight Saving Time: Skipped and Doubled Runs

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/cron-daylight-saving-time

> What cron, systemd, Kubernetes, GitHub Actions, AWS, Quartz.NET, Hangfire, and Celery do when clocks skip or repeat an hour, and how to schedule around it.

**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](https://cronradar.com/blog/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](https://data.iana.org/time-zones/tzdb/northamerica)). The UK follows the EU rule: both changes happen at 01:00 UTC on the last Sunday of March and October ([tz database](https://data.iana.org/time-zones/tzdb/europe)). 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.

_Figure: In New York, 01:00-01:59 happens twice on November 1, 2026, so 30 1 * * * has two candidate instants, and 02:00-02:59 never happens on March 14, 2027, so 30 2 * * * has none. Whether a scheduler runs such a job once, twice, late, or not at all is in the table below._

## 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](https://cronradar.com/blog/cron-expression)). 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)](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/man/crontab.5#L174-L178) says times missing during the daylight saving change "will never match" and repeated times "will cause matching jobs to be run twice". [cron(8)](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/man/cron.8#L115-L127) 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](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/cron.c#L398-L479) 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](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/cron.c#L563-L598)). 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](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c#L228-L241)). `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](https://sources.debian.org/src/cron/3.0pl1-197/cron.c/#L195-L276)), and so does Ubuntu 24.04's 3.0pl1-184ubuntu2 ([cron.c](https://git.launchpad.net/ubuntu/+source/cron/tree/cron.c?h=applied/ubuntu/noble)). Its [cron(8)](https://manpages.debian.org/trixie/cron/cron.8.en.html) 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](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L889-L928)). 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](https://github.com/systemd/systemd/blob/v262.1/src/shared/calendarspec.c#L1330-L1462)), 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](https://github.com/systemd/systemd/issues/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](https://github.com/systemd/systemd/blob/v262.1/src/test/test-calendarspec.c#L212-L218)).

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](https://github.com/systemd/systemd/issues/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)](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html) describes as "independent of wall-clock time and timezones":

```ini
# /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.target
```

### Kubernetes CronJob

With `.spec.timeZone` set, Kubernetes 1.37 prefixes the schedule with `TZ=<zone>` ([cronjob_controllerv2.go](https://github.com/kubernetes/kubernetes/blob/v1.37.1/pkg/controller/cronjob/cronjob_controllerv2.go#L788-L806)) and computes run times with robfig/cron v3.0.1. That library walks forward an hour at a time in real time ([spec.go](https://github.com/robfig/cron/blob/v3.0.1/spec.go#L138-L159)). 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](https://github.com/kubernetes/kubernetes/blob/v1.37.1/pkg/controller/cronjob/utils.go#L269-L272)), so the second run creates a new Job. [kubernetes#121594](https://github.com/kubernetes/kubernetes/issues/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](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/#time-zones)). The [Kubernetes CronJob monitoring guide](https://cronradar.com/blog/kubernetes-cronjob-monitoring) 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](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule)). 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](https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#daylist-savings-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](https://docs.cloud.google.com/scheduler/docs/configuring/cron-job-schedules#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](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz.Tests.Unit/CronExpressionDstTests.cs#L89-L313)). [4.6.0](https://github.com/quartznet/quartznet/blob/v4.6.0/src/Quartz.Tests.Unit/CronExpressionDstTests.cs#L89-L402) 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](https://github.com/HangfireIO/Cronos/blob/v0.11.1/README.md#daylight-saving-time)). 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](https://cronradar.com/blog/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](https://docs.celeryq.dev/en/v5.6.3/userguide/periodic-tasks.html#time-zones)). Its docs don't describe daylight saving. [celery#10107](https://github.com/celery/celery/issues/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](https://data.iana.org/time-zones/tzdb/europe), and America/Santiago skips 00:00-00:59 in September and repeats 23:00-23:59 on a Saturday night in April ([tz database](https://data.iana.org/time-zones/tzdb/southamerica)). 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:

```bash
#!/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](https://cronradar.com/blog/cron-dead-mans-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](https://docs.cronradar.com/schedules) 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:

```yaml
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.
