A cron expression can be perfectly valid and still run at the wrong time. No error, no warning. The job just fires on a day you didn't expect, or hours early, or twice. Here are the three mistakes behind most of these surprises, and how to avoid each one.
Quick refresher: a cron expression has five fields.
┌───────────── minute (0–59)
│ ┌─────────── hour (0–23)
│ │ ┌───────── day of month (1–31)
│ │ │ ┌─────── month (1–12)
│ │ │ │ ┌───── day of week (0–6, Sunday = 0)
│ │ │ │ │
0 9 * * 1-5
1. Setting both day fields (the OR rule)
What does this mean?
0 0 13 * 5
Most people read it as "midnight on Friday the 13th". Cron reads it as "midnight on the 13th of every month, or midnight on any Friday".
When both day fields are restricted (neither is *), classic cron runs the job when either one matches, not both. Starting from 7 November 2026, the next runs are:
Fri 13 Nov 2026 (the 13th, and also a Friday)
Fri 20 Nov 2026 (a Friday)
Fri 27 Nov 2026 (a Friday)
Fri 04 Dec 2026 (a Friday)
Fri 11 Dec 2026 (a Friday)
Sun 13 Dec 2026 (the 13th)
So instead of a couple of runs a year, you get one almost every week.
The same trap breaks the classic "first Monday of the month" attempt:
0 9 1-7 * 1 # runs on days 1–7 AND on every Monday
Standard cron can't say "first Monday". The usual workaround is to schedule days 1–7 and check the weekday in the command:
0 9 1-7 * * [ "$(date +\%u)" = 1 ] && /usr/local/bin/monthly-report
Note the \%: in a crontab, % means a newline and has to be escaped.
2. Forgetting the time zone
A cron expression has no time zone. The scheduler supplies one, and it's often not the one you're thinking in.
- A classic crontab uses the server's local time, which is UTC on many cloud machines.
- GitHub Actions schedules always run in UTC.
-
Kubernetes CronJobs use the controller's zone unless you set
spec.timeZone(supported since Kubernetes 1.27).
So "every weekday at 9" written as 0 9 * * 1-5 in a GitHub workflow runs at 09:00 UTC. That's 05:00 in New York during summer time and 14:30 in India.
The fix is boring but it works: decide which zone the schedule is meant for, write it in a comment next to the expression, and check the next run times in that zone before you merge.
on:
schedule:
# 09:00 UTC = 14:30 IST
- cron: "0 9 * * 1-5"
(Quote the expression in YAML. A value that starts with *, like */15 * * * *, is read as an alias and the file fails to parse.)
3. Daylight saving time
If your scheduler runs in a zone with daylight saving time, two nights a year behave differently.
In New York in 2027:
-
14 March: clocks jump from 02:00 to 03:00. A job at
30 2 * * *has no 02:30 to run at. -
7 November: 01:00–02:00 happens twice. A job at
30 1 * * *can run twice.
What happens next depends on the implementation. Vixie cron runs skipped jobs right after the jump, and other schedulers skip them. Either way, a "daily" job doesn't run exactly once that day.
Two safe options:
- Run schedulers in UTC, which has no daylight saving time.
- If you must use a local zone, keep daily jobs out of the 01:00–03:00 window.
Check before you trust it
All three mistakes have the same cure: look at the next run times of a new schedule, in the right time zone, before it goes live. If the list surprises you, the expression is wrong. The syntax is fine.
I built a free Cron Expression Parser that translates an expression into plain English, lists the next runs in any time zone and flags runs that fall into a DST gap. It runs in your browser.
For the full reference (special characters, 20 tested examples, six-field formats and "can cron run every 10 seconds?"), see the complete guide: Cron Expressions Explained.
What's the strangest cron schedule bug you've run into?
Top comments (0)