The Day-of-Month / Day-of-Week OR Trap
Cron's most-surprising behavior: when you set both the day-of-month and day-of-week fields to something other than `*`, the job runs on days matching either field, not both. The expression `0 0 1 * 1` doesn't fire "on the 1st only if it's a Monday" — it fires on the 1st of every month AND every Monday, which is a very different schedule. Here's why, where it bites, and what to do about it.
Updated
The behavior
The expression 0 0 1 * 1 looks like it should fire “at midnight on the 1st of the month, but only if the 1st is a Monday.” It doesn’t. In Unix cron, Kubernetes CronJob, GitHub Actions, and every other POSIX-compatible scheduler, it fires:
- At midnight on every 1st of the month (12 times per year), AND
- At midnight every Monday (~52 times per year)
That’s about 60 runs per year, with a small overlap when the 1st actually does fall on a Monday. Not 1–2 runs per year as you might expect.
The rule, from POSIX:
If both
day-of-monthandday-of-weekare restricted (not*), the entry shall match when either field matches the current time.
Why does it work this way?
The original BSD crontab implementation chose OR semantics in the 1970s. The reasoning, as best as can be reconstructed from old commentary:
- Most users only set one of the two day fields at a time. Setting both was rare enough that the implementers picked the simpler-to-implement rule.
- OR avoided ambiguity about whether “both must match” would also need to handle “neither is set,” “one is set with a list,” etc. OR made every combination unambiguous: at least one field must match.
- AND would silently produce schedules that never fire.
0 0 31 2 *(February 31st) would be valid syntax with no runs. OR-ing day-of-week into it at least lets the user notice their mistake.
POSIX codified the behavior in 2001. Every Unix-derived cron implementation has kept it ever since.
Where this bites in production
The classic incident: an engineer writes a “monthly report on the first Monday” expression and gets paged when the report goes out every Monday for a month.
Example 1: “First Monday of the month at 9 AM”
0 9 1-7 * 1 # WRONG — fires on the 1st-7th AND every Monday
This fires 7 days per month (the 1st through 7th) AND every Monday — so about 7 + 5 = 12 runs per month, not 1.
The correct Unix workaround is to schedule on every Monday and gate inside the script:
# crontab
0 9 * * 1 [ "$(date +%d)" -le 7 ] && /usr/local/bin/first-monday-report.sh
In Quartz or AWS EventBridge, use #:
0 0 9 ? * 2#1 # Quartz: first Monday of every month at 9 AM
cron(0 9 ? * 2#1 *) # EventBridge: same
Example 2: “Quarterly review email on the 1st, but skip if it’s a weekend”
0 9 1 */3 1-5 # WRONG — fires on the 1st of every 3rd month AND every weekday
This fires on the 1st of January, April, July, October (4 days per year) PLUS every weekday (250+ days per year). Total: 250+ runs per year, not 4.
Workaround: schedule on the 1st of those months and gate inside the script:
0 9 1 */3 * [ $(date +%u) -le 5 ] && /usr/local/bin/quarterly.sh
Example 3: “Run on Mondays in March”
0 9 * 3 1 # CORRECT — both day fields are restricted but one is *
Wait, this one is actually correct. The month field is 3, the day-of-week field is 1, the day-of-month field is *. The OR rule only applies when both day fields are specific (DoM and DoW). With DoM as *, only DoW restricts the day. So this fires every Monday in March.
The trap is specifically when both DoM and DoW are non-*.
Workarounds
In Unix cron / Kubernetes / GitHub Actions, you have three options:
-
Schedule on the more frequent of the two intents, gate inside your script.
- For “first Monday of the month”: schedule on every Monday (
0 9 * * 1), checkdate +%dis ≤ 7 inside the script. - For “1st of the month only if weekday”: schedule on the 1st (
0 9 1 * *), checkdate +%uis ≤ 5 inside the script.
- For “first Monday of the month”: schedule on every Monday (
-
Schedule a broader set of days, exit early from the script if today isn’t the target. Similar to #1, but useful when neither field cleanly captures the intent.
-
Move to a more capable scheduler. Quartz, AWS EventBridge, and systemd timers all have richer expression syntax that handles these cases natively. If you’re hitting this often, your scheduler may be the problem.
How Quartz solves it (the ? placeholder)
Quartz (and AWS EventBridge, which inherited the rule) handles the conflict differently. Exactly one of the day-of-month and day-of-week fields must be ?. This says “this field is not constrained; the other one is in charge.”
0 0 9 ? * MON-FRI # Quartz: weekdays at 9 AM (DoM is ?)
0 0 0 1 * ? # Quartz: midnight on the 1st of every month (DoW is ?)
0 0 9 ? * 2#1 # Quartz: 9 AM on the first Monday of every month
You cannot set both DoM and DoW to specific values in Quartz — it’s a parse error. The price of avoiding the OR trap is that you have to be explicit about which field is doing the work.
Porting expressions across dialects
When copying a cron expression from Unix to Quartz/EventBridge:
-
If only one of DoM/DoW is set, replace the other one’s
*with?.0 0 1 * *(1st of month) becomes0 0 0 1 * ?in Quartz,0 0 1 * ? *in EventBridge. -
If both DoM and DoW are set, you have to decide which one was the real intent. The Unix expression was probably wrong to begin with (OR rule firing more often than intended) — rewrite it with the workaround pattern above.
-
Watch the DoW numbering — Unix uses Sun=0, Quartz/EventBridge use Sun=1. Prefer name form (
MON,TUE, etc.) to avoid the off-by-one.
The parser tool lets you paste an expression, switch the dialect dropdown, and immediately see whether the schedule changes. The OR trap shows up as a much higher run count than expected — try 0 0 1 * 1 in Unix mode and watch the next-runs list fill up.
Frequently asked questions
What does `0 0 1 * 1` actually do?
Why doesn't cron AND the two day fields instead?
Does this apply to every cron dialect?
How do I actually get "only when both day-of-month AND day-of-week match"?
Related
First of Month
`0 0 1 * *` — the unambiguous monthly schedule.
PatternEvery Monday
`0 0 * * 1` — the unambiguous weekly schedule.
ToolCron Parser
Try `0 0 1 * 1` in the parser and see how often it actually fires.
GuideHow Cron Expressions Work
Field-by-field guide to the standard 5-field cron syntax.
GuideCron Dialect Comparison
How Unix, Quartz, K8s, EventBridge, Spring, and GitHub Actions differ.