AppCrib
Developer Tools

When 5 Cron Fields Aren't Enough: The Seven-Variant Landscape

Domain knowledge·Published by AppCrib··
LintcronSee exactly which cron field is wrong, and why.

The job was supposed to fire at 02:30 every Sunday. Instead it ran 24 times every Sunday, once an hour at HH:02:30, and nothing the rest of the week. The crontab line read 30 2 * * 0 and looked correct. The problem wasn't the syntax. It was that the scheduler reading the line wasn't classic Unix cron. It was Quartz, which expects a six-field expression, and 30 2 * * 0 was being parsed with 30 consumed as the seconds field, shifting every other field one slot left.

Cron has been a dialect war for years. The five-field POSIX form is the one most documentation shows, but a substantial portion of the cron expressions running in production today belong to one of seven different variants. Each has its own field count, special characters, and opinions about whether days-of-week start at 0 or 1. The variants matter because schedules silently fail to run when the expression is parsed by the wrong dialect, and almost nothing about the failure tells you that's what happened.

The five-field POSIX baseline

The original cron from V7 Unix (released 1979, written by Brian Kernighan) used five fields:

* * * * *
│ │ │ │ │
│ │ │ │ └─ day of week (0-6, Sunday = 0)
│ │ │ └─── month (1-12)
│ │ └───── day of month (1-31)
│ └─────── hour (0-23)
└───────── minute (0-59)

This is what you get from crontab -e on Linux and macOS. POSIX standardized this exact form. Vixie cron, the variant most distributions actually ship, added a few useful extensions (@hourly, @daily, @reboot, ranges with steps like */15, named months and days), but the five-field structure is unchanged. Every expression that works in classic cron is exactly five space-separated fields.

Three things to know about the day-of-week field specifically:

  • Sunday is 0 *or* 7. Both work in Vixie cron. POSIX says 0.
  • Day-of-week and day-of-month combine with OR, not AND. 0 0 1 * 0 runs at midnight on the first of the month *or* on Sunday, not the first that's also a Sunday. This trips people up regularly.
  • The minute field doesn't have the OR quirk. 30 * * * * is unambiguous.

If you stay inside Vixie cron, none of the variant problems below apply to you.

Quartz: six or seven fields, JVM ecosystem

Quartz Scheduler is the cron implementation in many JVM applications: Spring scheduler in older configurations, ActiveMQ, Hadoop, Jenkins (sort of), and a long tail of enterprise tools. Quartz expressions have an extra leading seconds field by default:

0 30 2 * * ? *
│ │  │ │ │ │ │
│ │  │ │ │ │ └─ year (optional, 1970-2099)
│ │  │ │ │ └─── day of week (1-7, Sunday = 1, OR `?`)
│ │  │ │ └───── month (1-12)
│ │  │ └─────── day of month (1-31, OR `?`)
│ │  └───────── hour (0-23)
│ └──────────── minute (0-59)
└────────────── seconds (0-59)

Six required fields plus an optional year (making seven total). Two consequences:

First, the day-of-week numbering is shifted by one. Sunday is 1 in Quartz, not 0. Pasting a POSIX expression into a Quartz scheduler shifts every weekly schedule by a day.

Second, Quartz forbids using both day-of-month and day-of-week at the same time. One of them must be ? (the literal question mark, meaning "no specific value"). The OR semantics that classic cron uses are explicitly disallowed. 0 0 0 1 * 1 is invalid in Quartz; you have to pick one of 0 0 0 1 * ? (first of every month) or 0 0 0 ? * 1 (every Sunday).

The ? requirement catches almost everyone the first time. Spring's documentation refers to this as a feature rather than a quirk, but in practice it means you can't paste a classic crontab line into a Quartz config and expect it to parse.

Spring: similar to Quartz, not identical

Spring Framework's @Scheduled annotation has its own cron parser, which superficially looks like Quartz but disagrees on a few points:

  • Spring is six fields, not seven. No year support.
  • Spring does allow both day-of-month and day-of-week in the same expression, with OR semantics like classic cron. The ? character is allowed but no longer required.
  • Sunday is 0, not 1. Spring went back to the POSIX numbering.
  • Day-of-week names (MON, TUE, etc.) are accepted in any case, like Vixie cron.

Spring is a hybrid: Quartz-like in field count, classic-like in semantics. Migrating from Quartz to Spring or back is a frequent source of one-day-shifted schedules and "why is this firing twice a month" bugs.

AWS EventBridge: six fields, year required

AWS uses a six-field cron for EventBridge rules (formerly CloudWatch Events). The field layout matches Quartz minus the seconds:

30 2 ? * 1 *
│  │ │ │ │ │
│  │ │ │ │ └─ year (required)
│  │ │ │ └─── day of week (1-7, Sunday = 1, OR `?` or `L` or `#`)
│  │ │ └───── month (1-12)
│  │ └─────── day of month (1-31, OR `?` or `L` or `W`)
│  └───────── hour (0-23, UTC only)
└──────────── minute (0-59)

AWS shares Quartz's day-of-week starting at 1, the ? requirement when one of day-of-month or day-of-week is unused, and the special characters (L for last-of-period, W for nearest weekday, # for nth weekday of month). It adds a wrinkle: the year field is required, not optional. Most expressions use * for year, but you have to write it.

EventBridge also runs everything in UTC. Local time is your problem to convert. A schedule that says "every Tuesday at 6 PM Eastern" needs to be expressed as 0 23 ? * 3 * (UTC), and you need to remember to shift it twice a year for daylight saving. AWS won't do it for you.

Apache Airflow: POSIX five-field plus macros

Airflow uses standard five-field cron expressions internally, with macro shortcuts that mirror Vixie's @daily, @hourly, etc. It does not implement Quartz's seconds field or AWS's year field. From an expression-syntax standpoint, Airflow is the closest to "just classic cron" of any of the major modern schedulers.

Where it differs is in interpretation: Airflow's start_date and schedule_interval interact in ways that surprise people new to it (the famous "execution_date is the previous interval, not the current one" problem), but that's about scheduling semantics, not expression parsing.

Kubernetes CronJob: classic five-field

Kubernetes CronJobs use five-field expressions parsed by robfig/cron, a Go library that implements Vixie cron with Quartz-like extensions optional. By default, K8s sticks to five fields. No seconds, no year. The ? character is not supported, and day-of-month and day-of-week combine with OR.

A direct comparison

VariantFieldsDOW start?Combine DOM + DOWYearSeconds
Vixie cron (Linux/macOS)50 (Sun)NoORNoNo
Quartz6 or 71 (Sun)RequiredForbiddenOptionalYes
Spring60 (Sun)OptionalORNoYes
AWS EventBridge61 (Sun)RequiredForbiddenRequiredNo
Airflow50 (Sun)NoORNoNo
K8s CronJob50 (Sun)NoORNoNo

A footnote on systemd timers: they're the modern Linux replacement for cron in many distributions, but they use a completely different syntax (OnCalendar=Mon..Fri 09:00) that isn't cron-compatible at all. If you're moving a job from a crontab to a systemd timer, you're translating, not pasting.

The patterns that work across (almost) all of them

If you need an expression that works the same in classic cron, Spring, and Airflow without modification, three rules:

  1. Stick to five fields. Quartz and AWS will reject this; everything else accepts it.
  2. Use day-of-week names (MON, TUE) instead of numbers, when the parser supports them. This dodges the 0-vs-1 disagreement entirely.
  3. Don't combine day-of-month and day-of-week in the same expression. Pick one. This dodges both the OR-vs-forbidden problem and the surprise "fires twice a month" behavior.

For expressions that need to work in Quartz or AWS, the additional rules are: use ? in whichever of day-of-month or day-of-week you're not using, and write out the seconds field (or year, for AWS) explicitly.

The most common silent failures

The bugs that don't throw an error and don't show up in logs:

  • Pasting a five-field classic expression into a Quartz config. The parser splits on whitespace and assigns the first field to seconds. 30 2 * * 0 becomes "30 seconds past minute 2 of every hour on Sundays." Looks scheduled, fires constantly.
  • Forgetting that AWS day-of-week starts at 1. Every weekly schedule shifts by a day.
  • Using 0 for Sunday in a Quartz expression. Quartz parses 0 as Saturday in some versions, errors out in others.
  • Combining day-of-month and day-of-week in classic cron and being surprised by OR. 0 0 1 * 1 runs on the first of the month *and* every Monday. If you want "the first Monday of every month" you need 0 0 1-7 * 1 (the only Monday in the first week).

These all parse cleanly, schedule something, and run on the wrong cadence. The way you find out is when the job-that-should-have-run didn't, or when something that should run weekly is hammering your infrastructure every minute.

Knowing which variant you're working with

The schedulers don't always advertise which variant they speak. Quick checks:

  • If the expression accepts six or seven fields and rejects five, it's Quartz.
  • If it requires a year field, it's AWS.
  • If it accepts five fields and treats 0 as Sunday, it's classic cron, Spring (if six fields work too), or robfig/cron.
  • If it has special characters like L and W, it's Quartz, AWS, or a Quartz-compatible parser.
  • If ? is required when omitting day-of-month or day-of-week, it's Quartz or AWS.

When you can't tell from the docs (and the docs are often unhelpfully vague), the fastest way to find out is to write an obviously-broken expression (six fields with all wildcards) and read the error. Each variant rejects different things in different ways.

If you'd rather not run that experiment in production, Lintcron parses an expression against the major variants and reports which ones accept it and what the next runs would be in each. Useful when you've inherited a crontab from a system that's been migrated through three schedulers and nobody remembers which one is canonical.

Lintcron
See exactly which cron field is wrong, and why.
Try Lintcron