Cron Jobs and Time Zones: Why Your Scheduled Job Ran at the Wrong Time

A cron job that "always runs at 2am" can quietly stop running at 2am twice a year — around daylight saving transitions — because most schedulers interpret cron expressions in the system's local time zone by default, and local time zones move. This is one of the most common, least-diagnosed classes of scheduling bugs, because the job usually looks fine 363 days a year.

The Core Problem: Cron Doesn't Know What "2am" Means

A standard cron expression like 0 2 * * * means "run at 2:00 in whatever time zone this scheduler is configured for." That's fine until that zone observes daylight saving. On the "spring forward" transition, the clock jumps straight from 1:59am to 3:00am — 2:00am never happens that day. Depending on the cron implementation, the job either fires immediately at 3:00am to catch up, or gets skipped entirely until the next day. On "fall back," 1:00am-1:59am happens twice, and some implementations run the job twice.

Server Time Zone vs. UTC vs. User Time Zone — Three Different Things

A single server often has three different notions of "now" in play at once: the OS's configured system time zone (often UTC on cloud instances, but not always — check with timedatectl on Linux), the time zone the cron daemon actually evaluates schedules in (usually inherited from the system, but crontab entries can sometimes override it via a CRON_TZ variable depending on the cron implementation), and the time zone of the humans who actually care when the job runs. Confusing these three is behind most of this category of bug.

The Fix: Schedule in UTC, Not Local Time

The simplest fix, and the one that scales to any number of servers: run your cron daemon in UTC and write schedules against UTC, not a human-friendly local time. UTC has no daylight saving transitions, so a job scheduled for 0 6 * * * always fires exactly 24 hours after the last time it fired — no ambiguity, no skipped or doubled runs. If a job genuinely needs to run at "6am for European users," compute the correct UTC hour for that specific date (accounting for whether DST is in effect that week) rather than hardcoding a fixed UTC offset that will drift by an hour twice a year.

# BAD: assumes the server's local time zone, which may observe DST 0 2 * * * /usr/local/bin/nightly-job.sh # BETTER: explicit UTC, immune to DST transitions TZ=UTC 0 6 * * * /usr/local/bin/nightly-job.sh

When You Actually Need "Local Time" Scheduling

Some jobs genuinely need to run relative to a specific region's local time — a report that should land in someone's inbox at 9am their time, for example. For that, don't hardcode a UTC hour; compute it fresh using the same technique covered in our Handling Timezones & UTC in JavaScript guide — get the target zone's current UTC offset, and derive the correct UTC time for that specific day. Some modern schedulers (Kubernetes CronJob with a timeZone: field since Kubernetes 1.27, or systemd timers with OnCalendar= and an explicit zone) support specifying an IANA time zone directly instead of a fixed offset — prefer that over computing the offset by hand whenever it's available, since it correctly follows DST changes automatically.

Distributed Systems Compound the Problem

A job that reads "run daily at midnight" means something different on a server in São Paulo than on one in Tokyo — and if different parts of a pipeline run on servers in different regions, a "same day" boundary can genuinely mean different windows of wall-clock time for each part. The safest default for any multi-server or distributed pipeline is to standardize every server's system clock and cron daemon on UTC, and only convert to a human's local time at the point where a person actually reads the output — the same "store in UTC, display in local time" principle that applies to databases and APIs (see our Time Zone Bugs in Databases & Logs guide).

Common Symptoms of This Bug

  • A nightly job that "randomly" runs an hour early or late twice a year, always around March/April and September/October/November — a near-certain sign it's scheduled in a DST-observing local time zone instead of UTC.
  • A job that appears to run twice in one day, or not at all, only around a "fall back"/"spring forward" date.
  • Two servers in the same pipeline producing results with a one-hour gap that only appears seasonally.

Related

If you're building the scheduling logic yourself rather than relying on cron, see Handling Timezones & UTC in JavaScript for the underlying technique, or the Meeting Planner to double-check what a given UTC time actually looks like in a specific region before you hardcode it.

Tools

Articles & Guides