The Cron Bug That Runs Your Job Far More Often Than You Meant
Set both day-of-month and day-of-week and cron ORs them, not ANDs. Why "first Monday" schedules misfire, and the fix.
Table of contents
You want a job to run on the first Monday of every month, so you write this:
0 9 1-7 * 1Day 1 through 7, on a Monday. That reads correctly in English and it is wrong. This expression runs at 09:00 on the 1st through the 7th of the month, and on every Monday of the year. Instead of 12 runs a year you get about 76.
The mistake is not in the ranges. It is in an operator that never appears in the expression.
Key takeaways#
- When both day-of-month and day-of-week are restricted, cron takes the union of them, not the intersection.
- A field is "restricted" only if it does not start with
*. That means*/2counts as unrestricted, which changes the behaviour of the whole expression. - There is no cron expression for "the first Monday of the month". The standard cannot express it.
- The reliable fix is to schedule the job more often than needed and exit early in a guard clause.
- Quartz and AWS EventBridge sidestep the problem with a
?field and 6-field syntax. Vixie cron does not.
Why does cron OR the two day fields?#
Because the specification says so. The crontab(5) man page for Vixie cron, the implementation behind cron on essentially every Linux distribution, states that if both the day-of-month and day-of-week fields are restricted, the command runs when either field matches the current time.
So the two day fields are not two filters narrowing one selection. They are two independent schedules, and cron runs the job on any day that appears in either one.
The canonical example from the man page itself:
30 4 1,15 * 5That runs at 04:30 on the 1st and the 15th of each month, plus every Friday. Not "the 1st or 15th, if it happens to be a Friday".
What counts as a "restricted" field?#
A field is unrestricted if it starts with *. This is narrower than it sounds and it is where the second surprise lives.
* is obviously unrestricted. But */2 also starts with *, so Vixie cron treats it as unrestricted too. That single character changes which rule applies to your whole expression:
| Day-of-month | Day-of-week | How they combine |
|---|---|---|
* | 1 | AND — every Monday |
1-7 | * | AND — the 1st to 7th |
1-7 | 1 | OR — the 1st to 7th, plus every Monday |
*/2 | 1 | AND — */2 is unrestricted, so only Mondays that are odd-numbered days |
The third row is the bug. The fourth row is the one that catches people who thought they understood the third.
How do I schedule a job for the first Monday of the month?#
You cannot, in a single cron expression. The five fields have no operator for "the Nth weekday of the month", and because of the OR rule you cannot build one by combining the day fields.
The standard workaround is to over-schedule and then filter inside the job. Run it on every candidate day and let the script decide:
# crontab: every Monday at 09:00
0 9 * * 1 /usr/local/bin/monthly-report.sh#!/bin/bash
# monthly-report.sh — exit unless today is in the first week
if [ "$(date +%-d)" -gt 7 ]; then
exit 0
fi
# ... the actual workDay-of-week is restricted, day-of-month is *, so no OR. The guard clause does what cron cannot express. It is also self-documenting in a way a clever expression never is, because the next person reads -gt 7 and knows exactly what was intended.
Some cron implementations, including the cronie package used on Red Hat derivatives, do not add an operator for this either. Reaching for the guard clause is not a workaround for a missing feature so much as the accepted way to do it.
Which schedulers behave differently?#
Not every scheduler that borrowed cron's syntax borrowed this rule.
- Quartz (Java) uses 6 or 7 fields and forbids specifying both day fields at once. One of them must be
?, meaning "no specific value". The ambiguity is designed out rather than resolved. Quartz also adds#, so6#3means the third Friday of the month. - AWS EventBridge uses a 6-field expression and likewise requires
?in one of the two day fields. - Kubernetes CronJob uses standard 5-field syntax and inherits the OR behaviour.
If you are copying an expression between systems, the day fields are the first thing to re-check. A Quartz expression pasted into a crontab loses its ? and changes meaning.
A quick audit of your existing crontab#
Run this against a crontab to find expressions where both day fields are restricted:
crontab -l | awk '!/^#/ && NF>=5 { if ($3 !~ /^\*/ && $5 !~ /^\*/) print "OR-bug: " $0 }'Any line it prints runs more often than its author probably intended. The !~ /^\*/ test matches cron's own definition of restricted, so */2 is correctly treated as unrestricted rather than flagged.
What to check before you ship a cron expression#
- Are both day fields restricted? If so, you have a union, and you almost certainly wanted an intersection.
- Does either field start with
*? If yes, that field is unrestricted regardless of what follows. - Are you targeting an Nth weekday? Move the logic into the job.
- Which scheduler is this actually for? The same five fields mean different things in Quartz.
- Which timezone does the daemon use? System cron runs in the host's local time, so a schedule that looks fine in CI can drift by an hour twice a year.
You can check an expression against a plain-English reading with the cron expression generator, which spells out the days a schedule actually selects rather than the days you meant to select. The difference between those two is the entire subject of this article.
The short version#
Cron's two day fields are a union, not an intersection, whenever both are restricted. It is documented behaviour rather than a bug in any particular implementation, which is why it has survived for decades and why it keeps catching people. When a schedule needs a condition cron cannot express, express it in the job instead. A guard clause costs three lines and never surprises anyone.
Sources: crontab.guru — cron bug, Oracle Solaris crontab documentation
Tags
- Cron
- Linux
- DevOps
- Scheduling
