Most cron cheatsheets stop at the five fields and a dozen examples, which is exactly the part nobody gets wrong. The failures come from the differences between schedulers: seconds fields that sit at the front in Quartz and at the back in croniter, weekday numbers that shift by one, day fields that OR in a crontab and AND in Spring. This page is the reference for those differences. To check a concrete expression, paste it into our cron expression parser, which speaks all of these dialects, translates between them and accepts crontab.guru URLs.
The five fields
A classic crontab entry is five schedule fields followed by the command. Read it as a sentence: at minute M, past hour H, on day-of-month D, in month M, on day-of-week W.
| Position | Field | Values | Notes |
|---|---|---|---|
| 1 | minute | 0–59 | |
| 2 | hour | 0–23 | 0 is midnight, there is no 24 |
| 3 | day of month | 1–31 | combines with field 5 via OR, not AND |
| 4 | month | 1–12 | names JAN–DEC allowed in Vixie-family crons |
| 5 | day of week | 0–7 | 0 and 7 are both Sunday; names SUN–SAT allowed |
Two details from the crontab(5) man page that trip people up. Names are only valid as single values: MON-FRI works on most modern crons, but ranges and lists of names are outside the spec, so portable files use numbers. And a value like * in day-of-month next to a restricted day-of-week does not mean "and": with both day fields restricted, the entry fires when either matches. That rule alone produces most of the mystery schedules; the full story, along with the DST window and the % that truncates commands, is in our cron pitfalls guide. This page stays on syntax.
Special characters, and who understands them
| Character | Meaning | Understood by |
|---|---|---|
* | every value | everyone |
, | list: 0,15,30,45 | everyone |
- | inclusive range: 9-17 | everyone |
/ | step: */5, 10-40/10 | Vixie extension; everywhere in practice, but not POSIX |
? | "no value" for the unused day field | Quartz, EventBridge (required in one day field); alias for * in Spring and Kubernetes |
L | last day of month; L-3, LW; 5L = last Friday | Quartz, Spring 5.3+, Cloudflare, EventBridge |
W | nearest weekday to a date: 15W | Quartz, Spring 5.3+, Cloudflare, EventBridge |
# | nth weekday of the month: MON#1 | Quartz, Spring 5.3+, Cloudflare, EventBridge |
H | stable per-job hash, for load spreading | Jenkins; croniter with hash_id |
@daily & co. | macros for common schedules | Vixie-family, Kubernetes, Spring; not POSIX, not GitHub Actions |
The macros are @yearly, @monthly, @weekly, @daily, @midnight, @hourly and, only in real crontabs, @reboot, which fires when the daemon starts after boot. Worth knowing before relying on it: at that point the network is often not up yet, and there is no retry.
The expressions you actually came for
All lines are classic 5-field crontab. For Quartz or Spring, put a 0 in front for seconds and fix the weekday numbers (see below).
| Goal | Expression | Note |
|---|---|---|
| Every minute | * * * * * | the smallest interval plain cron has |
| Every 5 minutes | */5 * * * * | fires at :00, :05, :10 … |
| Hourly, at half past | 30 * * * * | |
| Every 2 hours | 0 */2 * * * | at 00:00, 02:00, 04:00 … |
| Daily at 03:00 | 0 3 * * * | in the machine timezone, usually UTC |
| Weekdays at 09:00 | 0 9 * * 1-5 | 1 = Monday in crontab |
| Every 30 min, business hours | */30 9-17 * * 1-5 | last run 17:30, not 17:00 |
| Twice a day | 0 6,18 * * * | |
| Sunday at midnight | 0 0 * * 0 | 7 works too, but not everywhere |
| First of the month, 00:00 | 0 0 1 * * | |
| Quarterly | 0 0 1 1,4,7,10 * | Jan, Apr, Jul, Oct |
| Last day of the month | 0 0 28-31 * * + guard | guard: [ "$(date -d tomorrow +\%d)" = "01" ] |
| First Monday of the month | 0 9 1-7 * * + guard | guard: [ "$(date +\%u)" = "1" ]; not 1-7 * 1, that ORs |
| Every 90 minutes | two lines, see below | a single expression cannot express it |
The backslashes in the guards are load-bearing: inside a crontab, an unescaped % ends the command. And before one of these lines goes anywhere near production, run it through the parser and read the next five run times it computes; a schedule you can read back beats a schedule you believe.
Why */7 is not "every 7 minutes"
A step value does not mean "every N units of time". It means "every Nth value within the field's range, starting at the range start". The field resets at its boundary, and if the range does not divide evenly, the last interval is short:
| Expression | Actually fires at | Interval |
|---|---|---|
*/7 * * * * (minutes) | :00, :07, :14, :21, :28, :35, :42, :49, :56, then :00 | 7 min, except one 4-minute gap per hour |
0 0 1 */5 * (months) | Jan, Jun, Nov, then Jan again | 5, 5, then 2 months |
*/90 * * * * (minutes) | :00 only | silently an hourly job |
Steps that divide the range evenly (*/5, */10, */15, */20, */30 in minutes) behave exactly as read, which is why the trap survives: the common cases work. For a true every-90-minutes cadence, interleave two entries:
| Line | Fires at |
|---|---|
0 0-21/3 * * * | 00:00, 03:00, 06:00 … |
30 1-22/3 * * * | 01:30, 04:30, 07:30 … |
A step can also start from an offset: 10-59/15 in minutes fires at :10, :25, :40, :55. Since a range plus a step is Vixie syntax, a strictly POSIX cron accepts none of this; every step has to be spelled out as a list there.
Sunday is 0, 7 or 1, depending on who you ask
This is the single most damaging difference between dialects, because a shifted weekday field still parses. The schedule installs, runs, and quietly does everything one day off.
| System | Sunday | Monday | Week range |
|---|---|---|---|
| POSIX crontab | 0 | 1 | 0–6 |
| Vixie, cronie, Kubernetes, GitHub Actions | 0 or 7 | 1 | 0–7 |
| Spring 5.3+, node-cron, croniter | 0 or 7 | 1 | 0–7 |
| Quartz | 1 | 2 | 1–7 |
| AWS EventBridge | 1 | 2 | 1–7 |
So 0 9 * * 5 is Friday in a crontab and Thursday in Quartz or EventBridge. In Quartz-family systems, always write names (MON-FRI), which mean the same thing everywhere and remove the numbering question entirely. In portable crontabs, stick to 0–6 and never write 7.
The dialect matrix
The table we wanted every time an expression moved between a crontab, a Java service and a Python script, and never found in one place:
| Scheduler | Fields | Seconds | L W # | ? | Macros | Both day fields set |
|---|---|---|---|---|---|---|
| POSIX cron | 5 | no | no | no | no | OR |
| Vixie / cronie | 5 | no | no | no | yes, incl. @reboot | OR |
| Quartz | 6–7, seconds first, year last | yes, first | yes | required in one day field | no | parse error |
| Spring 5.3+ | 6, seconds first | yes, first | yes | alias for * | yes | AND |
| Jenkins | 5, plus H | no | no | no | yes, hashed (@daily = H H * * *) | OR |
| node-cron (npm) | 5–6, seconds first | optional, first | no | no | no | OR |
| croniter (Python) | 5–6, seconds last | optional, last | no | no | yes | OR, switchable to AND via day_or=False |
Three rows deserve a sentence each. Quartz refuses to guess what two restricted day fields mean and throws "Support for specifying both a day-of-week AND a day-of-month parameter is not implemented", which is at least honest. Spring intersects them, so 0 0 0 13 * FRI in Spring really is Friday the 13th, while the same idea in a crontab fires about nine times a month. And croniter puts the optional seconds field at the end of the expression unless you pass second_at_beginning=True, the exact mirror of Quartz, so a six-field expression means two different schedules in a Java and a Python codebase. The parser resolves each field per dialect and shows the OR and AND readings side by side.
Hosted cron: what the platforms accept
Every cloud scheduler wraps one of the dialects above and adds its own limits. The limits are what the docs bury:
| Platform | Syntax | Timezone | What bites |
|---|---|---|---|
| Kubernetes CronJob | 5 fields + macros; ? = * | UTC, or .spec.timeZone (stable since 1.27) | no L W #; unquoted */5 is a YAML alias error |
| AWS EventBridge | 6 fields: no seconds, year 1970–2199 last; L W # | UTC, or per-rule timezone | Sunday = 1; ? mandatory in one day field |
| GCP Cloud Scheduler | 5 fields, unix-cron | selectable per job | no seconds, no L W # |
| Azure Functions | NCRONTAB: 6 fields, seconds first | UTC, or WEBSITE_TIME_ZONE | 5-field input works but shifts meaning vs. crontab |
| GitHub Actions | 5 fields, no macros | UTC, optional IANA timezone | 5-minute floor, queue delays, 60-day auto-disable |
| Cloudflare Workers | 5 fields plus L W # | UTC only | Quartz extras but crontab weekday numbers |
| Vercel | 5 fields | UTC only | Hobby: one run per day, fires anywhere within the hour |
Two of these deserve emphasis, because they surface as "cron is broken" tickets. GitHub Actions schedules are pulled from a shared queue: GitHub documents that runs can be delayed in periods of high load and names the start of every hour as the busiest slot, so 0 * * * * is the worst possible choice there; schedule at minute 23 and the delay mostly disappears. The auto-disable is the other one: a public repository without activity for 60 days gets its scheduled workflows switched off, announced by a single email. Vercel's Hobby plan takes the opposite approach and documents that a daily job fires at some point within the scheduled hour, which is fine for a cache warmer and a surprise for anything a human is waiting on. If a CronJob manifest is involved, our YAML footgun linter catches the unquoted */5 before kubectl does.
Translating cron to systemd OnCalendar
On any systemd machine, timers are the other native scheduler, and their calendar syntax reads weekday, then date, then time. The translation is mechanical:
| Cron | OnCalendar= |
|---|---|
0 3 * * * | *-*-* 03:00:00 (or just 03:00) |
*/15 * * * * | *:0/15 |
0 9 * * 1-5 | Mon..Fri 09:00 |
0 0 1 * * | *-*-01 00:00:00 (= monthly) |
| last day of month + guard | *-*~01 00:00:00, no guard needed |
0 0 13 * 5 + weekday guard | Fri *-*-13 00:00, no guard needed |
The last two rows are the argument for learning the syntax at all: systemd combines a weekday with a date as AND, so Friday the 13th is one readable line, and the ~ counts days from the end of the month. Test any candidate with systemd-analyze calendar 'Fri *-*-13 00:00', which prints the next elapse times without installing anything, the same check our parser does for cron expressions. Timers also answer the two requests plain cron cannot: Persistent=true runs a schedule missed while the machine was off, and RandomizedDelaySec=300 is the systemd spelling of Jenkins' H.
That is the whole sheet. Syntax is the half of cron you can look up; the half that pages you at night, timezones, DST windows, the silent environment, lives in the pitfalls guide.
Syntax questions the table leaves open
Why does */7 * * * * not run every 7 minutes?
Because a step applies within the field range and restarts at every hour: */7 in the minute field matches minutes 0, 7, 14, 21, 28, 35, 42, 49 and 56, then jumps back to 0, so once per hour the gap is 4 minutes instead of 7. A step only means "every N minutes" when N divides 60 evenly, so 5, 10, 15, 20 and 30 behave as read and everything else drifts. For a true 7-minute cadence you need a scheduler that measures from the previous run, such as a systemd timer with OnUnitActiveSec=7min.
How do I run a cron job every 90 minutes?
Not with a single classic expression: the minute field resets every hour, so */90 in minutes matches only minute 0 and quietly becomes an hourly job. Use two lines that interleave, 0 0-21/3 * * * and 30 1-22/3 * * *, which together fire at 00:00, 01:30, 03:00 and so on. systemd timers express it directly with OnUnitActiveSec=90min, and Quartz cannot do it in one expression either, for the same reset reason.
How do I schedule a cron job for the first Monday of the month?
In Quartz: 0 0 9 ? * MON#1, where #1 means the first occurrence of that weekday in the month. Spring 5.3+ accepts the same # token with seconds first. Plain crontab has no #, and writing 0 9 1-7 * 1 fires on every day 1 to 7 plus every Monday, because two restricted day fields combine with OR, so schedule the date window and test the weekday in the command: 0 9 1-7 * * [ "$(date +\%u)" = "1" ] && /opt/report.sh, with the % escaped because it is special in a crontab.
Why is my AWS EventBridge cron expression not valid?
Two causes account for nearly all "Parameter ScheduleExpression is not valid" errors: EventBridge cron has six fields (minutes, hours, day-of-month, month, day-of-week, year, and no seconds), so a five-field crontab line is one field short, and it refuses a value in both day fields, so one of day-of-month and day-of-week must be a ?. The daily-at-03:00 shape is cron(0 3 * * ? *). While porting, renumber weekdays too: EventBridge counts 1 as Sunday through 7 as Saturday. For plain intervals, rate(15 minutes) avoids cron syntax entirely.
Why does my GitHub Actions scheduled workflow not run at the exact cron time?
GitHub serves schedule events from a shared queue and documents that they can be delayed during periods of high load, naming the start of every hour as the busiest slot; delays of several minutes are normal and runs can be dropped entirely. The floor is one run per 5 minutes, schedules are evaluated in UTC unless the trigger sets an IANA timezone, and in public repositories scheduled workflows are disabled automatically after 60 days without repository activity. Scheduling at an odd minute like 23 instead of 0 avoids the worst of the queue.
How often can Vercel cron jobs run on the Hobby plan?
Once per day per cron job, and the invocation lands somewhere within the scheduled hour: 0 8 * * * fires at any point between 08:00:00 and 08:59:59, because Vercel spreads Hobby traffic across the hour. Schedules more frequent than daily are rejected at deployment. The Pro plan lifts both limits, with per-minute schedules and minute precision. Two more properties to plan around: expressions are evaluated in UTC only, and cron jobs run against the production deployment, so a preview branch never triggers them.
What does H mean in a Jenkins cron schedule?
H is a hash of the job name, evaluated to a stable pseudo-random value within the field range, so H H * * * runs daily at a time that differs per job but never changes for the same job. It exists to break the thundering herd of every job scheduling at minute 0: H/15 * * * * still runs every 15 minutes, just at a per-job offset. Ranges work too, H(0-7) 2 * * * picks a fixed minute in the first eight. Python users get the same behaviour from croniter, which accepts H together with a hash_id argument.
How do I convert a cron expression to a systemd timer?
Translate the schedule into an OnCalendar= line, which reads weekday, then date, then time: 0 3 * * * becomes *-*-* 03:00:00, */15 * * * * becomes *:0/15, and 0 9 * * 1-5 becomes Mon..Fri 09:00. Verify with systemd-analyze calendar "Mon..Fri 09:00", which prints the next elapse times before anything is installed. Timers then add what cron lacks: Persistent=true runs a schedule that was missed while the machine was off, and RandomizedDelaySec spreads simultaneous jobs apart.