~/blog

Cron day-of-week numbering: POSIX vs Quartz vs AWS (the trap that bites everyone moving expressions between systems)

published

#cron#devops#kubernetes

Cron expressions look universal — five space-separated fields, asterisks, slashes, ranges. They are not. The day-of-week field alone has at least three numbering conventions in production runners, and the spec disagreements silently break every “copy this cron from Stack Overflow” attempt. This post catalogs the dialects you’ll meet, shows what they actually compile to, and gives the conversion rules.

The expression 0 9 * * 1 means different things

RunnerCompiled meaning
POSIX cron (Unix crontab)Mondays at 09:00
cron-parser (Node)Mondays at 09:00
Kubernetes CronJobMondays at 09:00
GitHub ActionsMondays at 09:00
Cloudflare Workers Cron TriggersMondays at 09:00
Quartz (Java)Sundays at 09:00
Spring @Scheduled (Quartz-style)Sundays at 09:00
AWS EventBridge SchedulerSundays at 09:00
Azure Functions (Quartz-style)Sundays at 09:00

Same string, six days apart depending on where you paste it. The cause: POSIX uses 0 = Sunday, 6 = Saturday. Quartz uses 1 = Sunday, 7 = Saturday.

The three dialects

1. POSIX (5-field, 0–6 dow with 0 = Sunday)

┌───────────── minute (0–59)
│ ┌───────────── hour (0–23)
│ │ ┌───────────── day of month (1–31)
│ │ │ ┌───────────── month (1–12 or JAN–DEC)
│ │ │ │ ┌───────────── day of week (0–6 or SUN–SAT, 0 = Sunday)
* * * * *

Used by: Unix cron, cron-parser (Node), Kubernetes CronJob, GitHub Actions, Cloudflare Workers, node-cron, agenda. The cron expression builder on this site follows this dialect.

2. Quartz (6 or 7 fields, 1–7 dow with 1 = Sunday)

┌───────────── second (0–59)
│ ┌───────────── minute (0–59)
│ │ ┌───────────── hour (0–23)
│ │ │ ┌───────────── day of month (1–31 or ?)
│ │ │ │ ┌───────────── month (1–12 or JAN–DEC)
│ │ │ │ │ ┌───────────── day of week (1–7 or SUN–SAT, 1 = Sunday)
│ │ │ │ │ │ ┌───────────── year (optional, 1970–2099)
* * * * * * ?

Used by: Quartz Scheduler, Spring @Scheduled (Quartz mode), Azure Functions (NCRONTAB is similar but with seconds optional). The leading seconds field shifts everything right by one.

3. AWS EventBridge (6 fields, 1–7 dow with 1 = Sunday, requires ? on one of dom/dow)

cron(<minutes> <hours> <day-of-month> <month> <day-of-week> <year>)

AWS requires you to use ? (no specific value) in exactly one of day-of-month or day-of-week, never both literal. Quartz-style numbering. Year is required.

cron(0 9 ? * MON-FRI *)      # weekdays at 9 AM UTC
cron(0 9 1 * ? *)            # 1st of every month at 9 AM UTC

4. @hourly, @daily, @weekly, @monthly, @yearly macros

These are recognized by Unix cron, several Node libraries, and Kubernetes (since 1.4). Translation:

Macro5-field equivalent
@hourly0 * * * *
@daily, @midnight0 0 * * *
@weekly0 0 * * 0
@monthly0 0 1 * *
@yearly, @annually0 0 1 1 *
@rebootruns once at startup (POSIX cron only)

GitHub Actions does not accept macros. Cloudflare Workers Cron Triggers does not. Many Node libraries accept some but not all. Translate to the explicit 5-field form before deploying.

Conversion rules between POSIX and Quartz

POSIX dow N  →  Quartz dow (N + 1)
Quartz dow N →  POSIX dow ((N - 1) % 7)

Or in plain English: POSIX Sunday is 0, Quartz Sunday is 1. Add 1 going Quartz-ward, subtract 1 going POSIX-ward.

Common pairs:

DayPOSIXQuartz / AWS
Sunday0 (or 7)1
Monday12
Tuesday23
Wednesday34
Thursday45
Friday56
Saturday67

POSIX cron actually accepts both 0 and 7 for Sunday — to ease the porting friction. Quartz does not.

Day-of-month and day-of-week interact differently

POSIX evaluates 0 0 1 * 1 as the 1st of the month OR every Monday — whichever fires. So if the 1st is a Wednesday, the job fires the 1st and the next Monday. The standard semantics is OR when both fields are constrained.

Quartz uses ? as a “no specific value” wildcard. You’re not allowed to constrain both dom and dow simultaneously — use ? on the one you don’t care about.

POSIX:   0 0 1 * 1     → 1st of month OR every Monday
Quartz:  0 0 0 1 * ?   → 1st of month, day-of-week irrelevant
Quartz:  0 0 0 ? * MON → every Monday, day-of-month irrelevant

If a Quartz-flavor system rejects your POSIX expression, this is usually why.

Lookup-bench: */N step values

All three dialects use */N for stepping. */15 in the minute field means “every 15 minutes starting at 0”. Caveat: AWS EventBridge documentation occasionally inconsistent — when in doubt, write out the list explicitly: 0,15,30,45.

How DST bites

The cron expression 0 2 * * * (daily at 2 AM) fires:

Defenses:

Quick sanity check before deploying

0 9 * * 1-5    # POSIX/k8s/GHA/CF: weekdays at 09:00
0 0 9 ? * 2-6 *   # AWS EventBridge: weekdays at 09:00 UTC (Mon=2, Fri=6)
0 0 9 ? * MON-FRI *   # AWS EventBridge: same, named days

Compile-time difference: 4 fields → 6 fields, dow shift +1, year required, ? on dom.

Tooling

The cron expression builder and tester on this site uses the POSIX dialect (0 = Sunday). It describes any 5-field expression in plain English and previews the next 10 fire times in your chosen timezone — for sanity-checking before you paste into crontab, a Kubernetes manifest, a GitHub Actions workflow, or any other POSIX-compatible runner.

For Quartz or AWS EventBridge expressions, do the numeric translation first, then verify with the runner’s own documentation. The translation rule is just ±1 on the day-of-week field — most other things are the same.