Try the Cron Explainer

Why 0 0 1 * 1 Runs 63 Times a Year: The Cron OR Trap

Cron ORs the day-of-month and day-of-week fields, so 0 0 1 * 1 runs 63 times a year instead of once. That plus step-value gaps explains most cron scheduling bugs.

September 5, 2026 7 min read
Share: Facebook WhatsApp LinkedIn Email
Why 0 0 1 * 1 Runs 63 Times a Year: The Cron OR Trap

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 restrictedOR 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:

  1. Paste the cron expression.
  2. Read the plain-English description.
  3. 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.

Ask AI about this article
Share: Facebook WhatsApp LinkedIn Email

Cron Explainer

Free, instant results — no sign-up required.

Open Cron Explainer →
Similar Tools
Regex Tester URL Encoder/Decoder HTML Entities Number Base Converter JSON Formatter Password Generator Color Converter REST API Checker
How the Cron Daemon Works: OS Scheduling, Process Priority, and When to Use systemd Timers Instead
Developer
How the Cron Daemon Works: OS Scheduling, Process Priority, and When to Use systemd Timers Instead
Cron and Daylight Saving Time: Why Your 2:30 AM Job Didn't Run — or Ran Twice
Developer
Cron and Daylight Saving Time: Why Your 2:30 AM Job Didn't Run — or Ran Twice
Cron Doesn't Know What Timezone You Mean — The Server Timezone, Database Desync, and Kubernetes TimeZone Fix
Developer
Cron Doesn't Know What Timezone You Mean — The Server Timezone, Database Desync, and Kubernetes TimeZone Fix
Kubernetes CronJob vs Traditional Cron — ConcurrencyPolicy, TimeZone Support, and Why Serverless Changes Everything
Developer
Kubernetes CronJob vs Traditional Cron — ConcurrencyPolicy, TimeZone Support, and Why Serverless Changes Everything