X Xerobit

Cron Pitfalls: Timezones, DST, and Missed Runs

Your cron job will run twice or skip entirely when DST changes. Here's why, how UTC solves it, and how to configure timezone-aware schedulers correctly.

Mian Ali Khalid · · 8 min read
Use the tool
Cron Builder
Build and parse cron expressions with human-readable explanations.
Open Cron Builder →

Twice a year, in most of North America and Europe, the clock jumps forward or falls back by one hour. Most software handles this transparently. Cron does not. Jobs scheduled during the transition window either run twice or are silently skipped — no error, no log entry, no alert.

If your database backup, report generation, or billing job runs at 2 AM, you have a timezone problem and may not know it yet.

How cron uses timezones

Classic cron (/etc/cron on Linux, macOS cron) reads the system timezone to decide when to fire jobs. The system timezone is set in /etc/timezone or /etc/localtime. There is no per-job timezone configuration in POSIX cron — the whole daemon runs in one timezone.

When you write 0 2 * * *, cron means: “run at 2:00 AM local time.” If local time is America/New_York, that means Eastern time, which shifts between UTC-5 (EST) and UTC-4 (EDT) during DST transitions.

The spring-forward problem (jobs that don’t run)

In North America, spring-forward happens on the second Sunday of March: clocks jump from 2:00 AM directly to 3:00 AM. The hour from 2:00–2:59 AM does not exist.

If you have a job scheduled for 0 2 * * * (2:00 AM daily), that job will not fire on the spring-forward night. Cron checks “is it 2:00 AM?” but that timestamp never arrives. The job silently skips.

If you have a job at 30 2 * * * (2:30 AM), same result — the entire 2 AM hour is gone.

For most jobs, a missed nightly run is annoying. For billing runs, data pipelines with SLA dependencies, or jobs that cascade into downstream processes, it can cause production incidents.

The fall-back problem (jobs that run twice)

Fall-back happens on the first Sunday of November in North America: clocks jump from 2:00 AM back to 1:00 AM. The hour from 1:00–1:59 AM happens twice.

If your job runs at 30 1 * * * (1:30 AM), it will fire once during the first 1:30 AM (before the clock falls back), and again during the second 1:30 AM (after the clock falls back).

For idempotent jobs — daily reports, read-only exports — double-firing is usually harmless. For jobs with side effects — sending emails, charging customers, modifying records — it can be catastrophic. Duplicate invoices, duplicate email sends, double-applied discounts.

This failure mode is particularly insidious because:

  1. It happens only twice a year
  2. It produces no error — the job succeeds both times
  3. It’s easy to blame on something else if you’re not actively monitoring for duplicate runs

The fix: schedule in UTC

UTC never observes Daylight Saving Time. UTC never has a 2 AM that doesn’t exist or happens twice. Scheduling in UTC eliminates both problems completely.

Set the cron timezone to UTC:

Most Linux distributions allow per-user timezone override via the CRON_TZ environment variable in the crontab:

CRON_TZ=UTC
0 6 * * *    # runs at 6 AM UTC — 1 AM EST / 2 AM EDT, consistently
30 22 * * *  # runs at 10:30 PM UTC every day, no DST issues

For system crontabs (root’s jobs), you can also set TZ=UTC at the top of /etc/crontab.

The trade-off: you need to mentally convert UTC to your local time when reading the schedule. That’s worth it.

Specific scheduler configurations

systemd timers

systemd timers support timezone explicitly in the OnCalendar= directive:

[Timer]
OnCalendar=*-*-* 02:00:00 UTC
Persistent=true

Persistent=true is important: if the system was asleep or off when the timer should have fired, systemd will run the job immediately on next boot. Classic cron silently skips missed runs; systemd can catch them.

AWS EventBridge (CloudWatch Events)

EventBridge cron expressions use UTC by default. There is no DST issue out of the box. However, EventBridge also supports an at() expression that accepts a timezone:

# UTC cron — safe
cron(0 6 * * ? *)

# Rate expression — also UTC
rate(1 day)

If you need local-timezone scheduling for business reasons (e.g., “run at 9 AM New York time”), EventBridge Scheduler (the newer service) supports timezone-aware cron and rate expressions with a FlexibleTimeWindow parameter.

Kubernetes CronJobs

Kubernetes CronJobs historically used the cluster node’s system timezone, which is typically UTC for cloud-hosted clusters. Since Kubernetes 1.27, the timeZone field is stable:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "0 2 * * *"
  timeZone: "UTC"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: reporter
            image: myreporter:latest

If you’re on Kubernetes < 1.27, rely on the cluster being UTC (which it usually is for managed clusters like EKS, GKE, AKS).

GitHub Actions scheduled workflows

GitHub Actions on: schedule uses UTC:

on:
  schedule:
    - cron: '0 6 * * 1-5'  # 6 AM UTC on weekdays

No timezone configuration needed — GitHub Actions is always UTC.

What about business-hours scheduling?

Some jobs genuinely need to run at business-local times: “send the morning digest at 8 AM New York time, every weekday.” UTC makes this slightly awkward because the UTC equivalent shifts by an hour with DST.

Options:

  1. Accept the 1-hour shift: for most business use cases, “8 AM in winter, 9 AM in summer” (or vice versa) is acceptable. Schedule at a fixed UTC time.
  2. Use a scheduler that understands timezones: AWS EventBridge Scheduler, Kubernetes 1.27+, Quartz Scheduler, most modern task queue systems (Celery, Sidekiq, Delayed::Job) support IANA timezone names.
  3. Move the logic into the job: schedule the job every 30 minutes; the job itself checks whether it’s within the target window in the local timezone.

Monitoring for double-runs and missed runs

Regardless of timezone handling, these monitoring patterns catch scheduling problems:

Heartbeat monitoring: use a dead man’s switch (Healthchecks.io, Cronitor, AWS EventBridge). The job sends a ping on success; if the ping doesn’t arrive within the expected window, alert. This catches missed runs.

Idempotency keys: jobs that write to a database should include an idempotency mechanism — a unique key per scheduled run based on the scheduled timestamp (not the execution timestamp). Duplicate runs produce a constraint violation, not a duplicate record.

Alerting on duplicate job IDs: if your job runner logs a job ID, monitor for the same job ID appearing twice in a 24-hour window.

Summary

Classic cron runs in the system timezone and doesn’t account for DST. Spring-forward causes jobs in the missing hour to silently not run. Fall-back causes jobs in the repeated hour to run twice. The fix is simple: schedule everything in UTC using CRON_TZ=UTC (per-user crontab) or use a scheduler that supports explicit timezone configuration. Validate your schedule with the Cron Builder.

Further reading


Related posts

Related tool

Cron Builder

Build and parse cron expressions with human-readable explanations.

Written by Mian Ali Khalid. Part of the Dev Productivity pillar.