--- title: "Your cron job fires on the wrong day" author: "Abel Solutions" published: "2026-07-30T19:11:42.991Z" updated: "2026-08-18T00:45:00.300Z" url: "https://zyvop.com/your-cron-job-fires-on-the-wrong-day-ohffa" canonical: "https://dev.to/abel-dev/your-cron-job-fires-on-the-wrong-day-5a71" tags: ["AWS","cron","DevOps","Kubernetes"] --- # 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]().