Cron Jobs and Time Zones
· timezonesbugs
A job at 02:30 local never runs in spring and runs twice in autumn. The fix is to schedule in UTC and make the job idempotent.
Classic cron evaluates schedules against the system’s local time. Twice a year local time is discontinuous, and cron has to decide what to do about it. Different implementations decide differently.
The two failure cases
Spring forward. Local time jumps 01:59:59 → 03:00:00. A job scheduled for 30 2 * * * has no 02:30 to run at.
Fall back. Local time replays 01:00–01:59. A job scheduled for 30 1 * * * sees 01:30 twice.
What each implementation does
Vixie cron (the traditional Linux default) has special handling for jobs scheduled within three hours of a transition — that is, wall-clock jobs rather than interval jobs like */15:
- On a forward jump, skipped jobs are run once, immediately after the transition.
- On a backward jump, jobs that would repeat are run only once.
This is a genuine attempt to do the right thing, and it works for simple daily jobs.
cronie (RHEL, Fedora) behaves similarly, with its own edge cases.
systemd timers are the best-behaved option. OnCalendar supports an explicit zone, and Persistent=true runs missed jobs on the next boot:
[Timer]
OnCalendar=*-*-* 02:30:00
Timezone=UTC
Persistent=true
Kubernetes CronJob uses UTC by default. Since v1.27, spec.timeZone accepts an IANA name — and the documentation explicitly warns against scheduling inside a DST transition window.
Cloud schedulers — AWS EventBridge uses UTC for cron() expressions. Google Cloud Scheduler and Azure both accept a time zone. Read the documentation rather than assuming; the defaults differ.
Application-level schedulers (Quartz, Celery beat, Sidekiq, node-cron) each have their own rules. Many default to the process’s local zone, which is whatever the container inherited.
Do not rely on any of it
The safe approach does not depend on which implementation you have:
1. Run the daemon in UTC. Set the host or container to UTC and write all schedules in UTC. UTC has no transitions, so the ambiguity never arises.
sudo timedatectl set-timezone UTC
In a container, set TZ=UTC explicitly — the default depends on the base image.
2. Avoid the 01:00–03:00 window. Even in UTC, if anything downstream converts to local time, scheduling outside that window costs nothing and removes the risk entirely. Prefer something like 04:15 UTC. Avoiding exactly-on-the-hour also spreads load, since everyone else schedules at :00.
3. Make jobs idempotent. This is the one that actually saves you. A job that can safely run twice is immune to the entire problem — as well as to retries, overlapping runs and manual re-runs.
INSERT INTO daily_report (report_date, total)
VALUES (CURRENT_DATE, 42)
ON CONFLICT (report_date) DO UPDATE SET total = EXCLUDED.total;
4. Guard against overlap. If a run can exceed its interval, take a lock:
*/5 * * * * /usr/bin/flock -n /tmp/sync.lock /opt/sync.sh
5. Log the epoch. Have jobs record date +%s alongside local time. When you are reconstructing what happened around a transition, unambiguous timestamps are worth a great deal.
When local time genuinely matters
Some jobs must run at a local wall-clock time — a 09:00 daily summary for users in a particular market, or a billing cycle tied to a legal jurisdiction.
The pattern that works: schedule frequently in UTC, and let the job decide whether to act.
# Runs hourly in UTC; the job checks whether it is 09:00 anywhere it cares about
0 * * * * /opt/send-summaries.sh
from datetime import datetime
from zoneinfo import ZoneInfo
for tz_name in MARKET_ZONES:
now = datetime.now(ZoneInfo(tz_name))
if now.hour == 9 and not already_sent_today(tz_name, now.date()):
send_summary(tz_name)
mark_sent(tz_name, now.date())
The already_sent_today check is what makes this safe: during a fall-back the 09:00 hour is not repeated, but the pattern is robust regardless, and it handles multiple zones from one schedule.
Keeping tzdata current
Any scheduler that converts to local time needs current zone rules. Rules change several times a year by political decision, sometimes with weeks of notice.
# Debian/Ubuntu
sudo apt-get install --only-upgrade tzdata
# Check what your system thinks
zdump -v America/New_York | grep 2026
Rebuild container images periodically for this reason alone — a two-year-old image has two-year-old rules. Runtimes that bundle their own copy (Java, Go with embedded tzdata, Node with full ICU) need updating separately from the OS.
See daylight saving bugs for the wider class of problem, and the time zone pages for this year’s transition dates.