Cron Expression Parser

What a cron expression actually does, said in English, plus the next times it fires

Next runs

Nothing yet.

Field by field

Nothing yet.

Five numbers, each one a set of values, and a job that runs whenever the clock matches all of them at once.

+------------- minute        0-59
| +----------- hour          0-23
| | +--------- day of month  1-31
| | | +------- month         1-12  or  JAN-DEC
| | | | +----- day of week   0-7   or  SUN-SAT   (0 and 7 are both Sunday)
| | | | |
* * * * *

Each field takes a value, a list, a range, a step, or * for all of them. */5 is every fifth value from the bottom of the range,1-5 is a range, 1,15 is a list, and they combine:0-30/10,45 is 0, 10, 20, 30 and 45.

The one rule that is an OR

0 0 1 * MON

Reads as:  "midnight on the 1st, and midnight every Monday"
Not as:    "midnight on the 1st, if it is a Monday"

Roughly 5 runs a month, not 1 or 2 a year.

Day-of-month and day-of-week are both about which day, and when both are restricted the job runs when either matches. Every other combination of fields is an AND. This is not a quirk of one implementation; it is in the POSIX specification and in Vixie cron's man page, and it has been catching people out for forty years.

When both fields are set here, that is called out above the results. If you want "the first Monday of the month", five fields cannot say it. The usual workaround is 0 0 1-7 * MON with a check inside the job itself, or Quartz's MON#1 if you are on Quartz.

Steps do not span the field

*/20 * * * *      :00  :20  :40         evenly spaced
0,20,40 * * * *   the same thing, written out

*/45 * * * *      :00  :45   then back to :00
                  a 45-minute gap and then a 15-minute one,
                  because the pattern restarts every hour

A step counts from the bottom of the field and stops at the top. It does not carry over. So */45 in the minute field does not mean "every 45 minutes"; it means "at minute 0 and minute 45 of every hour", which is a 45-minute gap followed by a 15-minute one, forever.

Anything that does not divide evenly into 60 has this problem: 7, 8, 9, 11, 13, 14, 25, 40, 45. Easiest way to spot it is the next-run list above. The gaps stop being equal.

Four dialects, and the day they disagree about

Unix        min  hour  dom  mon  dow                 Sunday is 0 and 7
Seconds     sec  min   hour dom  mon  dow            Sunday is 0 and 7
Quartz      sec  min   hour dom  mon  dow  [year]    Sunday is 1
EventBridge min  hour  dom  mon  dow  year           Sunday is 1

The same five characters can mean different days in different
schedulers. 0 0 * * 1 is Monday everywhere except Quartz and
EventBridge, where it is Sunday.

Quartz adds three modifiers that plain cron has no way to express, and EventBridge borrowed them:

WrittenMeans
L in day-of-monthThe last day of the month, whatever its number
LWThe last weekday of the month
15WThe weekday nearest the 15th, without crossing into another month
FRI#3The third Friday of the month
FRILThe last Friday of the month
?No value here. Required in one of the two day fields

? exists precisely because of the OR rule above. By insisting that one of the two day fields carries no value, Quartz and EventBridge make the ambiguity impossible to write.

Cron does not know about time zones, until it does

A crontab entry runs against the system clock of the machine it is on. Move the machine, change the server's zone, or run the same job in two regions, and the schedule moves with it.

Then twice a year the clock itself moves:

Runs that fall in a skipped hour are left out of the list above and counted in a note, because pretending they exist would be worse than saying they do not. Dodge the whole problem by scheduling in UTC, as Kubernetes CronJobs, EventBridge and most container schedulers do by default.

Where cron is not what you want

Cron answers "when", never "did it work". It has no notion of a job that is still running when the next one is due, no retry, no backoff, no history, and no alert when a run fails. A schedule that overlaps itself will happily start a second copy on top of the first.

Two habits cover most of it: take a lock at the start of the job so a slow run cannot be overtaken, and have the job report somewhere when it finishes, so that silence is detectable. Everything past that is what a scheduler with a queue is for.

Shorthands

ShorthandSame as
@yearly, @annually0 0 1 1 *
@monthly0 0 1 * *
@weekly0 0 * * 0
@daily, @midnight0 0 * * *
@hourly0 * * * *
@rebootOnce, when the machine starts. Not a schedule at all

All of them run at the very top of their period, which means every@daily job on a machine starts at the same instant. Spreading them out by a few minutes each is worth doing before it becomes the reason something falls over at midnight.