cronuru
Guide

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-month and day-of-week are 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:

  1. 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.
  2. 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.
  3. 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:

  1. 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), check date +%d is ≤ 7 inside the script.
    • For “1st of the month only if weekday”: schedule on the 1st (0 9 1 * *), check date +%u is ≤ 5 inside the script.
  2. 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.

  3. 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:

  1. If only one of DoM/DoW is set, replace the other one’s * with ?. 0 0 1 * * (1st of month) becomes 0 0 0 1 * ? in Quartz, 0 0 1 * ? * in EventBridge.

  2. 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.

  3. 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?
It fires at midnight on every 1st of the month AND every Monday — typically 12 monthly runs plus ~52 Monday runs, with overlap on the few Mondays that fall on the 1st. About 60 runs per year, not 1-2.
Why doesn't cron AND the two day fields instead?
Historical convention going back to early Unix cron. The original BSD implementation chose OR because most users only set one field at a time, and OR avoided the ambiguity of "do they both have to match?" Once the behavior was established, every Unix-compatible cron kept it — POSIX even codified it. Quartz and EventBridge introduced the `?` placeholder to give users an explicit way to opt out of the conflict.
Does this apply to every cron dialect?
All POSIX-compatible Unix dialects (crontab, anacron, Kubernetes CronJob, GitHub Actions, Vercel cron, Cloudflare Workers cron) OR the two day fields. Quartz and AWS EventBridge require you to set exactly one of them to `?`, which sidesteps the issue entirely. Spring's @Scheduled is interesting — it allows both fields to be specific but the parser actually uses AND semantics (the opposite of Unix).
How do I actually get "only when both day-of-month AND day-of-week match"?
In Unix cron, you can't — at least not at the cron level. Schedule on the more frequent field (e.g., every Monday) and gate inside your script: `[ $(date +%d) = "01" ] && do-the-thing`. Or move to a more capable scheduler like Quartz or systemd timers.