DEV Community

Abel Solutions
Abel Solutions

Posted on

Your cron job fires on the wrong day

A team ports a job from a Quartz scheduler to a Unix crontab. The Quartz trigger ran Monday through Friday — day-of-week field 2-6, because Quartz numbers Sunday as 1, so Monday is 2 and Friday is 6. Someone copies that same numeric range, 2-6, straight into the new Unix crontab entry. Nobody touches the logic. Nobody gets an error. The cron daemon accepts the expression without complaint, because 2-6 is a perfectly valid day-of-week range in Unix cron too.

It just means something else there. Unix cron numbers Sunday as 0, so 2-6 is Tuesday through Saturday. The job now skips Monday and runs an extra day on Saturday — and because nothing crashes, nobody notices until someone asks why a report that's supposed to close out the work week is missing Monday's numbers and showing up on the weekend. That's the incident shape: not a crash, not a stack trace, just a schedule that's quietly wrong until a human cross-checks it against a calendar.

It's a symptom of a bigger problem: there is no single "cron standard." There are roughly eight dialects in common production use, and they agree on the broad strokes — five-ish fields, */,/-// operators — while disagreeing on the details that actually matter when you port an expression between systems.

Why: eight dialects, one shared ancestor

The lineage is short. POSIX/Unix cron (Version 7 Unix, 1970s) is the conservative baseline: five fields, no step syntax, no aliases. Paul Vixie's 1987 cron — what most Linux distributions actually ship — is the superset everyone means when they say "cron": it adds */n steps, named days and months, and @daily-style aliases. From there the tree splits two ways. Some systems fork Vixie's syntax and quietly change a rule or two: GitHub Actions and Kubernetes CronJobs both use 5-field, Vixie-style parsing, but they diverge from Vixie — and from each other — on aliases. GitHub Actions rejects @-aliases outright; Kubernetes keeps them and adds @every on top. Neither accepts 7 as an alias for Sunday, though, even though Vixie cron does. Other systems reinvent the field layout from scratch: Quartz (Java) adds a seconds field and flips day-of-week numbering; Spring's @Scheduled borrows Quartz's advanced tokens but keeps Unix-style day-of-week numbering; AWS EventBridge adds a year field and borrows Quartz's ?/L/W/# tokens along with its Sunday-is-1 numbering.

Here's the full comparison, built directly from CronPro's dialect definitions:

Dialect Fields Sunday = Aliases (@daily, etc.) DOM/DOW "don't care"
Unix / POSIX 5: min hour dom mon dow 0 (no 7) None (strict POSIX) No ? token — ORs DOM and DOW when both are restricted
Vixie (Linux default) 5 0 (7 also accepted) @hourly@reboot No ? token — same OR rule
GitHub Actions 5 0 (7 not documented) None — explicitly rejected No ? token — ORs DOM and DOW when both are restricted
Kubernetes CronJob 5 0 (7 rejected by the API server) Yes (@hourly@every) No ? token — OR-behavior not stated explicitly for K8s in source
AWS EventBridge 6 (adds year) 1 None ? required in exactly one of DOM/DOW
Quartz 6–7 (adds seconds, optional year) 1 None ? required in exactly one of DOM/DOW
Spring @Scheduled 6 (adds seconds) 0 or 7 Yes (since Spring 5.3) ? accepted (5.3+), but not required in either field — semantics differ from Quartz/EventBridge
Azure Functions (NCRONTAB) 6 (adds seconds) 0 (no 7) None No ? token in this dialect — use *

Five traps that don't throw errors

1. Sunday's number changes between dialects. Unix, Vixie, and GitHub Actions number Sunday 0. Quartz and AWS EventBridge number it 1. That one-off shift is exactly what broke the crontab in the cold open: a Quartz 2-6 (Mon–Fri) becomes Unix 2-6 (Tue–Sat) if you copy the digits instead of re-deriving them. The fix is mechanical, not clever — re-map every numeric day value when you change dialects, or write the names (MON-FRI) instead of numbers, since day names mean the same weekday in every dialect that supports them.

2. Day-of-month and day-of-week are OR'd, not AND'd, when both are restricted. 0 0 1-7 * 1 looks like "the first Monday of the month." It isn't. When both fields carry a real restriction, Vixie-style cron fires the job if either matches — so that expression runs on every day 1 through 7 of the month, and on every Monday, whichever comes first. That's roughly 124 firings a year, not 12 — 84 from the date range plus 52 Mondays, minus the 12 days a year that are both (every month's first seven days contain exactly one Monday). This rule dates back to POSIX and holds in Vixie cron and the parsers derived from it — GitHub Actions documents the same behavior for schedule: triggers.

3. Kubernetes rejects 7 as an alias for Sunday. 0 0 * * 7 is valid in a Vixie crontab. Point the same string at a Kubernetes CronJob and the API server rejects it outright: end of range (7) above maximum (6). Same Vixie-style day-of-week field, but Kubernetes's parser drops the 0/7 dual-alias that Vixie cron allows. Use 0 0 * * 0 or 0 0 * * SUN instead — both work identically in both places.

4. In AWS EventBridge, the sixth field is year, not seconds. cron(0 12 * * ? *) runs daily at noon UTC — six fields, but the extra one past Unix's five is year, not sub-minute resolution. EventBridge has no seconds field at all. The other surprise in that same expression: the ? isn't decorative. EventBridge requires exactly one of day-of-month or day-of-week to be ?; cron(* * * * * *) is invalid because neither field opts out.

5. In Quartz, L in the day-of-week field means Saturday — literally the last day of the week — not "last occurrence in the month." It's an easy substitution to get backwards under a deadline: 0 0 0 ? * L fires every Saturday at midnight, full stop. For "last Friday of the month," the token you actually want is 6L (day 6, last occurrence), not L by itself.

The fix

For plain 5-field cron with no nth-weekday token, don't try to force the constraint into one expression. Schedule wide, then filter in the job itself: 0 0 1-7 * * fires on every one of the month's first seven days, and the job's first step exits unless it's actually the day you want — [ "$(date +%u)" = "1" ] || exit 0 for Monday. Two steps beat one clever, wrong one-liner, and the guard is portable to any shell the job already runs in.

Where the dialect gives you a native token, skip the workaround. Quartz and AWS EventBridge both support # for "nth weekday of the month" directly: 0 0 0 ? * 2#1 (Quartz) and cron(0 0 ? * 2#1 *) (EventBridge) both mean, literally, "the first Monday" — no guard clause, no wide schedule, no OR-semantics trap to fall into in the first place.

Where this leaves you

None of this is a defect in any one scheduler — each dialect is internally consistent, and the traps only show up at the seams, when an expression crosses from one system to another. We built a converter that handles the renumbering and de-stepping across all eight dialects and flags when a conversion is lossy — when the target dialect can't express what the source one did — instead of silently producing something that parses but means something different: cronpro.dev.

Top comments (0)