The Expression That Runs Far Too Often
0 0 1 * 1
Read that aloud and most people say: "midnight on the first of the month, if it's a Monday."
It doesn't mean that. It means midnight on the 1st of every month, AND midnight every Monday. In a typical year that's around 63 executions rather than the one or two you were expecting.
This is cron's single most surprising behaviour, and it catches experienced engineers regularly. The day-of-month and day-of-week fields are combined with OR, not AND — but only under specific conditions, which makes it even harder to reason about.
The Five Fields
┌───────────── minute (0–59)
│ ┌─────────── hour (0–23)
│ │ ┌───────── day of month (1–31)
│ │ │ ┌─────── month (1–12)
│ │ │ │ ┌───── day of week (0–7, both 0 and 7 = Sunday)
│ │ │ │ │
* * * * *
Every field except the two day fields is combined with AND. The expression fires when the minute matches AND the hour matches AND the month matches.
The two day fields are the exception.
The OR Rule Precisely
The behaviour depends on whether each day field is restricted:
- Both day fields are
*→ the day is unrestricted. Runs every day. - Only day-of-month is restricted (day-of-week is
*) → AND applies normally. Straightforward. - Only day-of-week is restricted (day-of-month is
*) → AND applies normally. Straightforward. - Both are restricted → OR applies. Fires if either condition matches.
That last case is where everything goes wrong. Examples:
0 3 15 * * → 3am on the 15th. Predictable.
0 3 * * 5 → 3am every Friday. Predictable.
0 3 15 * 5 → 3am on the 15th AND 3am every Friday. Surprise.
The rationale is historical — it lets you express "the 1st and the 15th and every Sunday" in one line. Whether that was worth the confusion is debatable, but the behaviour is documented in POSIX and implemented consistently across Vixie cron, cronie, and most compatible schedulers.
The practical rule: never restrict both day fields in one expression. If you genuinely need "the 15th only if it's a Friday", cron cannot express it. Schedule it for every 15th and check the weekday in your script:
[ "$(date +%u)" = "5" ] || exit 0
Step Values and Where They Trip People
The / operator means "every nth", applied over a range:
*/15 * * * * → every 15 minutes: 0, 15, 30, 45
0 */6 * * * → every 6 hours: 00:00, 06:00, 12:00, 18:00
*/20 * * * * → 0, 20, 40
The crucial detail: steps count from the start of the range, not from now, and not evenly around the wrap.
*/7 * * * * fires at minutes 0, 7, 14, 21, 28, 35, 42, 49, 56 — then the hour rolls over and it fires again at minute 0. That's a 4-minute gap between :56 and the next :00, not 7. Any step value that doesn't divide evenly into the range produces this uneven boundary.
Same issue with */45 * * * *: minutes 0 and 45, then 0 again. Gaps of 45 and 15 minutes alternating, not "every 45 minutes."
If regular intervals matter, use a step that divides the range cleanly. For minutes: 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30. For hours: 1, 2, 3, 4, 6, 8, 12.
You can also apply a step to an explicit range:
0 9-17/2 * * 1-5 → 9am, 11am, 1pm, 3pm, 5pm on weekdays
That's often clearer than trying to force */2 into a working-hours window.
Lists, Ranges and Names
0 8,12,18 * * * → list: 8am, noon, 6pm
0 8 * * 1-5 → range: weekdays
0 8 1,15 * * → 1st and 15th
0 8 * * MON,WED,FRI → names, where supported
0 8 * JAN,JUL * → month names
Name abbreviations are supported by most modern crons but not universally, and names don't work with step values or in all range positions in some implementations. Numbers are portable; names are readable. For anything that has to run across different systems, use numbers.
Note that day-of-week accepts both 0 and 7 for Sunday. That's deliberate — it lets you write 1-7 for Monday through Sunday.
Special Syntax
@reboot → at daemon startup
@yearly → 0 0 1 1 * (also @annually)
@monthly → 0 0 1 * *
@weekly → 0 0 * * 0
@daily → 0 0 * * * (also @midnight)
@hourly → 0 * * * *
These are readable and worth using where they fit.
@reboot deserves a warning. It runs when the cron daemon starts, which is not necessarily when the system is fully up. Network, mounted filesystems and dependent services may not be ready. For anything with startup dependencies, a systemd unit with proper After= and Requires= declarations is a much better fit.
The Thundering Herd
Look at any fleet of servers and you'll find a spike of activity at exactly 0 0 * * * and another at 0 * * * *. Everyone writes @daily and @hourly, and everything fires simultaneously.
If those jobs hit a shared database, an external API, or a backup target, you've built a self-inflicted load spike.
Stagger deliberately. Instead of 0 * * * * on twelve servers, use 3 * * * *, 8 * * * *, 13 * * * * and so on. Instead of everything at midnight, spread across the quiet hours.
Avoid minute zero for anything that touches a shared resource. It's the most contended minute of every hour by a wide margin.
Add jitter for large fleets. A short random sleep at the start of the script spreads the load without requiring per-host crontabs:
sleep $((RANDOM % 300))
Reading and Verifying Expressions
Before deploying any non-obvious expression, verify what it means. The Cron Explainer translates an expression into plain English:
- Paste the cron expression.
- Read the plain-English description.
- Check it against your intent.
The high-value check is exactly the OR trap. Paste 0 0 1 * 1 and read the description — if it says something other than what you assumed, you've caught a bug that would otherwise have surfaced as mysterious extra runs weeks later.
Also worth verifying: any expression with a step value that doesn't divide its range evenly, and anything using named days or months.
Practical Tips
Comment every crontab line. Six months later, 0 4 * * 6 means nothing without a note saying why Saturday at 4am.
Redirect output. Cron mails stdout and stderr to the crontab owner by default, which either floods a mailbox nobody reads or fails silently if mail isn't configured. Send output to a log file:
0 4 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
Don't assume your shell environment. Cron runs with a minimal environment — a short PATH, no profile sourcing, no aliases. Use absolute paths for every binary and set variables explicitly at the top of the crontab.
Escape percent signs. In a crontab, % is a newline character in the command. date +\%Y-\%m-\%d needs the backslashes, and forgetting them is a classic silent failure.
Make jobs idempotent. Given the OR trap, DST edge cases, and the possibility of retries, any job that runs twice should produce the same outcome as running once.
Monitor for absence, not just failure. A job that stops running produces no error. Use a dead man's switch — a heartbeat the job pings on completion, with an alert if the ping doesn't arrive.
FAQ
Does 0 0 1 * 1 run once a month?
No. It runs on the 1st of every month and every Monday, because both day fields are restricted and cron ORs them.
How do I run something on the last day of the month?
Standard cron can't express it. Run daily and check in the script whether tomorrow is the 1st: [ "$(date -d tomorrow +%d)" = "01" ]. Some extended implementations support an L character for this.
Why does */7 produce an uneven gap?
Steps count from the start of the range. Since 7 doesn't divide 60 evenly, the last interval before the range wraps is shorter.
What's the difference between 0 and 7 in day-of-week?
Nothing — both mean Sunday. Two values exist so that both 0-6 and 1-7 cover a full week.
Should I use @reboot?
Only for jobs with no startup dependencies. For anything that needs the network or a mounted volume, use a systemd unit with explicit ordering instead.
The Takeaway
Cron's syntax looks like five independent constraints, and it almost is — the day fields are the exception, and that exception accounts for a disproportionate share of scheduling bugs. Read any expression that restricts both day fields carefully, pick step values that divide their range, and stagger anything that shares a resource.
Translate any cron expression into plain English free with the Cron Explainer at sadiqbd.com — no sign-up, instant results.