Xerobit
Dev Productivity

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.

MA Mian Ali Khalid 8 min read
Free tool
Cron Builder
Build and parse cron expressions with human-readable explanations.
Open tool →
On this page ⌄
  1. How cron uses timezones
  2. The spring-forward problem (jobs that don't run)
  3. The fall-back problem (jobs that run twice)
  4. The fix: schedule in UTC
  5. Specific scheduler configurations
  6. What about business-hours scheduling?
  7. Monitoring for double-runs and missed runs
  8. Summary
  9. Further reading

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

Keep reading

Cron Expression Syntax, Explained by Field

Five fields, nine special characters, a dozen edge cases. A complete reference for cron syntax, minute, hour, day, month, weekday, with annotated real-world examples.

Unix Timestamps and Timezones: What Can Go Wrong

Unix timestamps are always UTC, but displaying, parsing, and storing them is where most timezone bugs live. The common pitfalls and exactly how to avoid them.

AWS EventBridge Scheduler: Cron Schedules for Lambda and AWS Services

AWS EventBridge Scheduler runs Lambda functions, ECS tasks, and other AWS targets on a cron or rate schedule. Learn how to create schedules with cron expressions, handle...

Cron Expression Guide: Syntax, Fields, and Special Characters

Cron expressions schedule recurring jobs using five or six fields: minute, hour, day-of-month, month, day-of-week. Here's the full cron syntax including *, /, -, and ?...

Cron Job Scheduling: How Cron Works and Best Practices

Cron runs scheduled tasks using 5-field time expressions. Learn how cron daemons work, how to manage crontabs, timezone handling, error logging, and alternatives like systemd...

Cron Job Syntax: How to Write and Read Cron Expressions

Cron job syntax is five space-separated fields: minute, hour, day-of-month, month, day-of-week. Here's how to read and write cron expressions, with common schedule examples.

Cron Jobs in Next.js with Vercel Cron: Scheduled Functions

Run scheduled tasks in Next.js using Vercel Cron (serverless cron), GitHub Actions schedules, or Upstash QStash. Includes configuration, securing cron endpoints, and...

Cron Schedule Examples: Common Cron Expressions for Any Interval

Cron expressions schedule recurring jobs. Here's a reference of common cron schedules: every minute, hourly, daily, weekly, monthly, and business hours, with explanations for...

GitHub Actions Scheduled Workflows: cron Syntax and Examples

Schedule GitHub Actions workflows with cron expressions using the schedule trigger. Learn the cron syntax, timezone behavior (UTC only), avoiding missed runs, and examples for...

Kubernetes CronJob: Schedule Recurring Tasks in Kubernetes

Kubernetes CronJob runs containers on a cron schedule inside your cluster. Learn the CronJob spec, concurrency policy, job history limits, timezone support, and debugging...

Related tools

Written by Mian Ali Khalid. More guides in Dev Productivity.