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)