Skip to content
Blog

Why Is My Cron Job Not Running? 10 Common Cron Expression Mistakes

Ten cron expression mistakes that cause most silent failures - OR'd date fields, timezone drift, missing output redirects - with minimal examples and fixes.

Share:Twitter/XLinkedIn

Your cron job didn't run. Or worse - it ran sixty times when it should have run once. Cron packs a schedule into five fields of terse punctuation, and a single wrong character turns a nightly backup into a flood of pager alerts.

Almost every "why is my cron not running" moment comes down to the same handful of mistakes. Here they are, each with the symptom you'll see in the wild and the exact fix.

The 5-field anatomy

┌──────────── minute (0–59)
│ ┌────────── hour (0–23)
│ │ ┌──────── day of month (1–31)
│ │ │ ┌────── month (1–12)
│ │ │ │ ┌──── day of week (0–6, Sun–Sat)
│ │ │ │ │
* * * * *

Each field takes a value, a range, a list, a step (*/n), or a wildcard (*). Position is everything - 0 9 * * * is not the same as 9 0 * * *. If you ever aren't sure what a line means, paste it into the cron expression parser and watch the next five run times.


1. * vs 0 in the minute field

Symptom: the job you meant to run hourly is firing every minute.

* * * * * means every minute of every hour. 0 * * * * means minute 0 of every hour, i.e. once per hour. The wildcard is "any value", not "the first value."

* * * * *   →  every minute           (60× per hour)
0 * * * *   →  top of every hour      (1× per hour)

If you wrote * * * * * expecting "once per hour," you'll rack up 60× the work before you notice.

2. 5/10 isn't the same as */5

Both use the step operator /, but they start in different places.

*/5  * * * *   →  0, 5, 10, 15, 20 ...   (every 5)
5/10 * * * *   →  5, 15, 25, 35 ...      (start at 5, add 10)

When you want "every N minutes," lead with * - */N. a/b is the rarer "start at a, step by b" form.

3. Day-of-month and day-of-week are OR'd, not AND'd

This one is the ambush - it looks like English and does the opposite.

0 9 15 * 1   →  9 AM on the 15th  OR  9 AM on every Monday

Not "9 AM on the 15th if it's a Monday." Standard cron (vixie, cronie) unions the two date fields whenever both are non-wildcard. POSIX specifies this behavior, so you can't assume it away.

Fix: pick one. Use day-of-week for weekday jobs, day-of-month for calendar-date jobs, and leave the other as *.

4. Using 7 for Sunday

The day-of-week field runs 0–6, where 0 is Sunday.

Value Day
0 Sunday
1 Monday
6 Saturday

Vixie and cronie accept 7 as a second alias for Sunday, but BusyBox and several embedded cron ports don't. Stick to 0-6 and your crontab ports cleanly between Linux, Alpine containers, and BSD.

5. 1-3-5 is not a range

Ranges use a single dash; lists use commas; you can mix them. Double dashes aren't a thing.

1,2,3 * * * *   →  minutes 1, 2, 3         (list)
1-3   * * * *   →  minutes 1, 2, 3         (range)
1-3,5 * * * *   →  minutes 1, 2, 3, 5      (mix)
1-3-5 * * * *   →  syntax error

If you want "1 through 5," write 1-5.

6. Assuming cron uses your local timezone

By default, cron runs in the system timezone. On most cloud hosts that's UTC. If you live in New York and write 0 14 * * * hoping for 2 PM lunch-hour reports, you'll get them at 10 AM EDT instead.

Pin the timezone explicitly at the top of the crontab:

CRON_TZ=America/New_York
0 14 * * * /usr/local/bin/report.sh

When you're debugging an off-by-N-hours job, the time zone converter and timestamp converter make the math mechanical instead of fraught.

7. No trailing newline

Old-school cron(8) implementations read the crontab line by line and stop at EOF. If your last entry doesn't end with \n, some daemons silently drop it. This bites hardest when the crontab was generated by a config management tool that produced a file without a final newline.

Leave a blank line at the bottom:

0 2 * * * /usr/local/bin/cleanup.sh

(Yes, that trailing empty line matters.)

8. Field order off by one

Cron's five positions are fixed. Swap any two and the schedule is unrecognizable.

30 2 1 * *   →  2:30 AM on the 1st of every month
30 2 * * 1   →  2:30 AM every Monday
2 30 1 * *   →  invalid - 30 isn't a valid hour

When in doubt, read the line right-to-left and name each field out loud: day-of-week, month, day-of-month, hour, minute. If the description doesn't match what you wanted, the expression is wrong.

9. Out-of-range values don't do what you'd hope

Each field has a hard range. Values outside it are handled inconsistently across daemons, and that's the real hazard:

Expression vixie-cron / cronie BusyBox cron
0 24 * * * refuses to load the file, logs bad hour to syslog treats 24 as 0, runs at midnight
0 * 32 * * refuses to load, logs bad day-of-month drops the entry silently
60 * * * * refuses to load, logs bad minute varies

So "will it run?" depends on which cron binary ships with your base image. On modern Linux (cronie, Debian/Ubuntu default) an out-of-range value kills the entire crontab load, not just the bad line. On Alpine's BusyBox you may get a half-working crontab you never notice. Valid ranges: minute 0–59, hour 0–23, day-of-month 1–31, month 1–12, day-of-week 0–6.

10. The job ran. You just can't see it.

Default cron mails stdout/stderr to the crontab owner. If your MTA isn't configured - which it isn't, on most containers and cloud VMs - every line of output vanishes.

Redirect explicitly:

0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

If you can run systemd timers instead, they log to the journal by default, show exit codes in systemctl status, and skip this whole class of problem.


Cron cheat sheet

Expression Meaning
* * * * * Every minute
*/5 * * * * Every 5 minutes
0 * * * * Top of every hour
0 0 * * * Daily at midnight
0 9 * * 1-5 9 AM every weekday
0 0 1 * * Midnight on the 1st of each month
0 0 * * 0 Midnight every Sunday
*/15 9-17 * * 1-5 Every 15 min, 9 AM–5 PM, weekdays

Testing before you deploy

Waiting 24 hours to find out whether 0 3 15 * 1 does what you think is a lousy debugging loop. The cron expression parser translates any expression into English and lists the next five run times, so you catch mistakes in seconds. If you're building a schedule from scratch, the cron expression generator gives you point-and-click fields plus the same preview.

For output-related debugging, a regex tester is handy when you're pulling job runs out of a noisy log file.


Workaround: sub-minute scheduling

Cron's minimum resolution is one minute - there's no way to say "every 30 seconds" in a single line. When you need sub-minute cadence, pair two entries and offset one with sleep:

* * * * * /scripts/job.sh
* * * * * sleep 30 && /scripts/job.sh

This is a workaround, not a mistake. It works, but it leaks a stray sleep process per minute and won't align cleanly across reboots. If your workload actually needs sub-minute timing, a long-running daemon or a systemd timer with OnUnitActiveSec=30s is a cleaner fit than cron.


The short version

Most "cron not running" incidents are one of four things: the wildcard isn't what you think it is, the two date fields are OR'd, the timezone isn't what the server thinks it is, or the output went to a mailbox no one reads. Validate every expression in the cron parser before you commit it, pin CRON_TZ, redirect stdout and stderr, and "why is my cron job not running" stops being a 2 AM question.

More in Developer Tools
Why Browser-Based Tools Are the Future
4 min read
How to Optimize Images Without Uploading
4 min read
Top 5 Developer Tools You Should Bookmark
5 min read