Reading cron expressions without getting tripped up

5 min read

Cron expressions are the kind of syntax that always feels readable until you try to read one. Anyone can decode 0 */6 * * * as “every six hours”, but 0 0 1,15 * 1-5 makes you reach for the docs again. This article walks through cron syntax with a focus on the parts that trip people up.

Basic structure: five fields separated by spaces

A standard cron expression has five fields, ordered from smallest to largest unit of time:

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

Things worth knowing up front:

  • There is no seconds field in standard cron. Quartz scheduler and some framework-specific extensions add a leading seconds field for six total, but the Linux crond baseline is five.
  • Day-of-week typically uses 0=Sunday, but a few implementations also accept 7 for Sunday. Don’t try both — check the docs and stick with one.
  • Months and days-of-week may accept names (JAN-DEC / SUN-SAT) in some implementations. Portability suffers, so prefer numbers.

Four operators: * , - /

You only need four to cover most uses.

* — any value

* * * * * means “every minute”. * represents every legal value for that field; in the minute field, it is equivalent to 0-59.

, — list of specific values

0 9,12,18 * * * runs at 9:00, 12:00, and 18:00 every day. Use it for non-contiguous values.

- — range

0 9-17 * * 1-5 runs at every hour from 9 to 17 on weekdays. Both endpoints are inclusive.

/ — step

*/15 * * * * is “every 15 minutes”. 0 */6 * * * is “every 6 hours”. */N means “fire at any value divisible by N”, so */15 lands on minutes 0, 15, 30, 45 — note that this is not “fire 15 minutes after the cron daemon starts”.

/ can combine with non-* operands too: 30-50/5 * * * * is “every 5 minutes between minute 30 and 50”. Rare in practice, but worth recognizing.

The biggest trap: day-of-month and day-of-week are OR’d

The single most common cron misunderstanding: when both field 3 (day-of-month) and field 5 (day-of-week) are set, cron evaluates them as OR, not AND.

0 0 1 * 1

Read literally, you might assume “midnight on the first of the month and a Monday”. In reality it means ”midnight on the first of every month, OR every Monday”. When neither field is *, cron fires whenever either condition is satisfied.

Both fields being * (e.g. 0 0 * * *, “midnight every day”) is unambiguous. Only one being non-* is also straightforward — only that field matters. The OR rule kicks in only when both are non-*.

“First Monday of the month” is awkward in standard cron

The OR rule means there is no clean way to express “first Monday of every month” in plain cron. A common workaround:

# fire on Monday and check the date in shell
0 0 * * 1 [ "$(date +%d)" -le 7 ] && /path/to/job

Quartz cron and similar extensions add # for “Nth weekday of month” (e.g. 0 0 0 * MON#1). Standard cron has no such operator. If your scheduling needs this much, consider switching to Quartz or a different scheduler from the start.

Common mistakes and what they actually do

What you wroteWhat you meantWhat it actually does
* */1 * * *Once an hourEvery minute (minute is *)
0 0 * * *Once an hourOnce a day at midnight
0 9-17/2 * * *9, 11, 13, 15, 179, 11, 13, 15, 17 (correct)
0 0 * * 7Sunday at midnightImplementation-defined (standard tops at 6)

“Once an hour” is 0 * * * * — fires only at minute 0. * */1 * * * fires every minute, which is how CI verification jobs end up running thousands of times before anyone notices.

Behavior shifts across implementations

Subtle differences across cron implementations are worth keeping in mind.

  • Time zonecrond follows the host TZ, but AWS EventBridge and GitHub Actions run on UTC. “Run at 9am” easily becomes UTC 9am = JST 6pm.
  • Non-existent dates0 0 31 * * only fires in months that have a 31st; shorter months are silently skipped.
  • DST — in regions that observe daylight saving time, the behavior of jobs scheduled in skipped or repeated hours varies by implementation.
  • Concurrent runscrond does not wait for the previous run to finish; if you don’t want overlap, add your own lock with flock or similar.

When in doubt about whether your expression does what you intend, the cron checker on this site lists the next several fire times. Visualizing the schedule beats reading by hand for catching mistakes.