Cron Expression Syntax, Timezone Drift, and Overlapping Job Pitfalls

Five space-separated fields govern scheduled workloads across Linux crontab, Kubernetes CronJob, GitHub Actions, and cloud event buses. While basic schedules look simple, subtle rules in the POSIX cron specification regularly trigger duplicate runs, uneven intervals, and skipped jobs in production.

1. The 5-Field POSIX Layout

Standard Vixie cron evaluates five fields in fixed order: minute, hour, day of month, month, and day of week. You can test any expression interactively and inspect upcoming execution times in the Cron Expression Parser.

Field Allowed Values Special Operators Notes
Minute 0-59 * , - / Zero-indexed minute of the hour
Hour 0-23 * , - / 24-hour clock (0 is midnight, 12 is noon)
Day of Month (DOM) 1-31 * , - / Months with fewer days silently skip out-of-range dates (e.g. Feb 30)
Month 1-12 * , - / 1 = January through 12 = December
Day of Week (DOW) 0-6 (or 7) * , - / 0 is Sunday in standard POSIX cron (some parsers also accept 7 as Sunday)

2. Trap #1: The Step Operator (*/N) Is Modulo, Not an Interval Timer

Developers often write */23 * * * * expecting a job to run every 23 minutes continuously. In cron, */N is a shorthand for matching field values where (value - min) % N === 0 within the field's reset boundary.

  • In the minute field (0-59), */23 fires at minutes 0, 23, and 46 of each hour.
  • After minute 46, the next execution happens at minute 0 of the next hour: a gap of 14 minutes instead of 23.
  • Only divisors of 60 (1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30) produce uniform minute intervals across hour boundaries.

3. Trap #2: Day-of-Month and Day-of-Week Use OR Semantics

Every field in a cron expression is combined with logical AND, with one POSIX exception: when both Day-of-Month (field 3) and Day-of-Week (field 5) are restricted (neither is *), standard Vixie cron matches the date if either field matches (logical OR).

# Intended: Run at midnight on Friday the 13th only
# Actual POSIX behavior: Runs on the 13th of EVERY month, AND on EVERY Friday!
0 0 13 * 5

To run a task strictly on Friday the 13th (or the first Monday of a month), trigger the cron on one condition and guard the second condition inside the command using date:

0 0 13 * * [ "$(date +\%u)" = "5" ] && /usr/local/bin/run-report.sh

4. Trap #3: Daylight Saving Time (DST) Skips and Double-Fires

If the host daemon runs in a local timezone that observes Daylight Saving Time (such as America/New_York or Europe/Berlin) rather than UTC:

  • Spring Forward: Clocks jump from 01:59:59 directly to 03:00:00. A job scheduled for 30 2 * * * (02:30 AM) does not run that day unless the cron implementation includes special DST catch-up logic.
  • Fall Back: The 01:00 to 01:59 hour occurs twice. Naive schedulers can fire a 15 1 * * * job twice on that Sunday.

Run production cron daemons, Kubernetes CronJob controllers, and database maintenance schedules in UTC (see Timezone Converter and Unix Timestamp Converter to map local windows to UTC).

5. Preventing Overlapping Executions

Cron launches a new process on schedule without checking whether the previous invocation finished. If a */5 * * * * job hits a slow database lock and takes 8 minutes, a second instance starts at minute 5, compounding load.

  • Linux host crontab: Wrap commands with flock -n /var/lock/myjob.lock /path/to/job.sh so overlapping ticks exit immediately with a non-zero code.
  • Kubernetes CronJob: Set concurrencyPolicy: Forbid in the manifest spec (default is Allow).