Why Your Cron Job Runs an Hour Late: UTC, Timezones and DST

Twice a year, jobs scheduled between 01:00 and 03:00 run twice or get skipped entirely. Here is why, and the scheduling rule that avoids it.

Why Your Cron Job Runs an Hour Late: UTC, Timezones and DST
Share this article:

A job scheduled for 02:00 has been running reliably for months. Then one Sunday in spring it does not run at all, and one Sunday in autumn it runs twice, corrupting a report because the second run reprocessed the same rows.

Nothing changed in your code. What changed was the clock.

Cron uses the host's timezone

Standard cron has no timezone concept of its own. It reads the system clock and the system timezone. If the host is set to `America/New_York`, `0 2 * * *` means 02:00 Eastern — and Eastern is UTC-5 in winter and UTC-4 in summer.

Check what your host actually thinks it is:

timedatectl                  # systemd hosts
cat /etc/timezone            # Debian/Ubuntu
date +"%Z %z"                # current abbreviation and offset

The most common version of "my job runs an hour late" is not DST at all — it is simply that the server is on UTC while you are reasoning in local time. A container image almost always defaults to UTC regardless of where it runs.

What daylight saving actually does to a schedule

Twice a year, local time is discontinuous. Take `America/New_York`:

EventClock behaviourEffect on `0 2 * * *`
Spring forward01:59:59 → 03:00:0002:00 never happens — job skipped
Autumn back01:59:59 → 01:00:0001:00–01:59 repeats — job in that window runs twice

A 02:00 job is skipped in spring. A 01:30 job runs twice in autumn. Both failures are silent: cron logs nothing unusual, because from its point of view nothing unusual happened.

Different implementations attempt different mitigations. Vixie cron — the common Linux default — tries to compensate for jobs scheduled during the skipped hour by running them once when the clock jumps. But behaviour varies between cron implementations and versions, and it is not something to depend on for anything that must be exactly-once.

The rule that avoids all of it

Schedule in UTC. Convert for humans at the edges.

UTC has no daylight saving. Every day is exactly 24 hours. A job at `0 6 * * *` UTC runs at the same instant every single day, forever, with no discontinuities to reason about.

Set it explicitly rather than relying on the host:

# In the crontab itself (Vixie cron 4.1+, most Linux distros)
CRON_TZ=UTC
0 6 * * *  /path/to/job.sh
# Kubernetes CronJob
spec:
  schedule: "0 6 * * *"
  timeZone: "Etc/UTC"

GitHub Actions schedules are always UTC and cannot be changed. AWS EventBridge defaults to UTC and lets you specify a timezone. Most managed schedulers now accept an explicit timezone — use it, and write it down next to the schedule.

When the job genuinely must run at a local time

Some jobs are tied to human activity: a 09:00 report for a Tokyo office, or a batch that must complete before markets open. For those, keeping local time is correct.

Two rules make it survivable:

1. Set the timezone on the scheduler, not the host. `CRON_TZ=Asia/Tokyo` documents intent. A host timezone can be changed by anyone doing unrelated maintenance. 2. Never schedule between 01:00 and 03:00 local. That is the window DST moves. A job at 00:30 or 04:00 is unaffected in both directions, for the cost of moving it by ninety minutes.

That second rule solves the majority of real DST incidents on its own.

Diagnosing a job that ran at the wrong time

Work through it in order:

1. What timezone is the host in? `date +"%Z %z"` on the machine that ran it, not your laptop. 2. What do the logs say in UTC? If your job logs Unix timestamps, paste one into the timestamp converter to see the exact instant. Unix timestamps are always UTC-based, which makes them the reliable ground truth. 3. Was it a DST changeover date? Check whether the run date was the changeover Sunday for that timezone. 4. Did the job overrun? Cron does not queue. If a run takes longer than the interval, the next one starts anyway, and overlapping runs look exactly like a scheduling bug. Use a lockfile:

0 * * * * flock -n /tmp/job.lock /path/to/job.sh

5. Is the container timezone what you think? Containers default to UTC. A job that looks an hour off in summer and correct in winter is almost always a UTC-versus-local mismatch, not DST.

Frequently asked questions

Does the server change timezone automatically for DST?

Yes, if the timezone is a region like `Europe/London` rather than a fixed offset like `UTC+0`. That automatic change is exactly what shifts your schedules.

Is `@daily` safer than `0 0 * * *`?

No. `@daily` is exactly `0 0 * * *` and is affected identically.

What if two regions need the same report at their own 09:00?

Two schedule entries, each with its own timezone. Do not try to express it as one.

How do I work out what 06:00 UTC is for my team?

The [timezone meeting planner](/timezone-meeting-planner) shows one instant across several zones at once, and the [world clock](/world-clock) gives current times side by side.

Why do my logs disagree with cron about when a job ran?

Usually because the log timestamps are local and the scheduler is UTC, or vice versa. Log in UTC with an explicit offset and this class of confusion disappears.

Related reading

The cron cheat sheet covers the twenty schedules that come up most, and last day of the month handles the month-end case that standard cron cannot express — a case where the timezone choice matters more than usual, because the month boundary itself moves.

Build a cron expression →