ZyVOP Logo
Content That Connects
SeriesAI NewsCategoriesWrite for Us
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Badges
  • Write Article
  • Newsletter

Company

  • About Us
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeYour cron job fires on the wrong day

Your cron job fires on the wrong day

Abel Solutions
Abel SolutionsDeveloper Tools Studio
July 30, 2026
5 min read
2 views
#cron#DevOps#Kubernetes#AWS
👍1

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.

Abel Solutions

Abel Solutions

Developer Tools Studio

Independent software studio building developer tools: CronPro (cron expressions across 8 dialects), RegexPro (regex testing across engines), and NuGate (open-source dependency age gate for NuGet). We write about the gotchas we hit building them.

Comments (0)

Login to post a comment.

Related Posts

The Alpine Mirage: How Upgrading Python Broke My Build and Led to a Truer Security Posture

A deep dive into how upgrading a Python Docker image to Alpine 3.15 caused cascading build failures across C/Rust dependencies—and why switching to Debian-slim with non-root privileges resulted in a faster, truly secure container architecture.

Read article

Monorepo Best Practices

I learned to love monorepos the hard way — by suffering through the alternative. At a large Canadian automotive company, we had a repository for basic UI compon...

Read article

Next.js Static Export to GitHub Pages: A Production Setup Guide

Static export remains the simplest deployment model for content-focused sites. This article documents the setup behind this very site — ma-x.im — a Next.js 15 p...

Read article

Zero-Downtime Deployments: Strategies and Tradeoffs

Every deployment is a gamble. You're swapping out a running system mid-flight. This post walks through the four main zero-downtime deployment strategies — blue-green, rolling, canary, and feature flags — with real-world examples, honest tradeoffs, and the one problem none of them fully solve: database migrations.

Read article

Building a Zero-Trust Internal API on AWS

A DynamoDB table, a read-only Lambda, an API Gateway locked to IAM auth, and an EC2 client with no SSH key: a full zero-trust internal API on AWS, with working Python and Node.js signing code and a private-API hardening step.

Read article