Cron expression gotchas: fields, ranges, and Vixie vs POSIX
The cron fields and special characters, the day-of-month vs day-of-week OR quirk that bites people, timezone and DST hazards, and the seconds-field portability trap.
A cron expression is five whitespace-separated fields that together describe when a job runs. The syntax looks trivial and mostly is, but a handful of details — one logical quirk, two implementation forks, and a timezone hazard — account for nearly every scheduled job that fires at the wrong time. This post walks the fields, the special characters, and the gotchas that survive code review and bite in production.
The five fields
| Field | Position | Allowed values | Notes |
|---|---|---|---|
| Minute | 1 | 0-59 |
|
| Hour | 2 | 0-23 |
24-hour clock |
| Day of month | 3 | 1-31 |
|
| Month | 4 | 1-12 or JAN-DEC |
names are Vixie, case-insensitive |
| Day of week | 5 | 0-7 or SUN-SAT |
0 and 7 both mean Sunday |
The line is read left to right: 30 2 1 * * is "minute 30, hour 2,
day-of-month 1, any month, any day-of-week" — 02:30 on the first of
every month. A field of * means "every valid value."
The day-of-week range is the first trap. Vixie cron accepts both 0
and 7 for Sunday, which means 0-6 and 1-7 both cover the full
week, and people writing 1-7 to mean "Monday through Sunday" are
surprised when it includes two Sundays' worth of matching. Stick to
0-6 or use names.
Special characters
Four operators compose the values within a field.
*— every value in the field's range.,— a list.0,15,30,45is four discrete minutes.-— an inclusive range.9-17is every value from 9 to 17./— a step.*/15in the minute field is "every 15 minutes" (0, 15, 30, 45). A step can ride on a range too:0-30/10is 0, 10, 20, 30.
A few examples that cover most real schedules:
*/15 * * * * # every 15 minutes
0 9-17 * * 1-5 # top of every hour, 9am-5pm, Mon-Fri
0 0 * * 0 # midnight every Sunday
0 2 1 * * # 02:00 on the 1st of every month
30 3 * * 6 # 03:30 every Saturday
*/15 is worth a closer look because the step is computed against the
field's full range, not against "now." */15 always fires at 0, 15,
30, 45 — it does not mean "15 minutes after the daemon started." A
step like */40 in the minute field fires at 0 and 40, then waits
20 minutes for the next hour to roll over to 0 again; it does not
fire at minute 80 because there is no minute 80. Steps that do not
divide their range evenly produce uneven gaps, which surprises people
expecting */40 to mean "every 40 minutes."
The day-of-month vs day-of-week OR quirk
This is the gotcha that most reliably catches experienced engineers.
Normally cron requires every field to match. But when both the
day-of-month field (3) and the day-of-week field (5) are restricted —
that is, neither is * — Vixie cron and the Linux crons derived from
it switch to OR logic between those two fields. The job runs when
the day-of-month matches or the day-of-week matches.
Consider this line, intended to run at midnight on Friday the 13th:
0 0 13 * 5 # NOT "Friday the 13th"
It does not do that. Because both day fields are restricted, cron runs the job at midnight on the 13th of every month and at midnight every Friday — far more often than intended. Friday the 13th is the intersection of those conditions, but cron computes the union.
The behavior is documented in the Vixie crontab man page and has been
stable since 1987, so it is a feature, not a bug — but it is
counterintuitive enough that crontab.guru maintains a page about it.
The practical rule: if you find yourself restricting both day fields,
stop and check whether you mean AND. If you do, you cannot express it
in a single standard cron line — you either add a date guard inside
the job ([ "$(date +\%a)" = Fri ] || exit 0) or split the logic out
of cron entirely.
When one of the two day fields is *, the OR rule does not apply and
everything behaves the obvious way.
Macros are a Vixie extension
Vixie cron added a set of nickname macros that replace the whole five-field expression:
@reboot # once, at daemon startup
@yearly # 0 0 1 1 * (also @annually)
@monthly # 0 0 1 * *
@weekly # 0 0 * * 0
@daily # 0 0 * * * (also @midnight)
@hourly # 0 * * * *
These are convenient and widely supported on Linux, but they are
not POSIX. The POSIX specification for crontab defines only the
five numeric fields. Run a crontab with @daily through a strict
POSIX cron, an embedded busybox build, or some BSD configurations and
it may be rejected or silently ignored. @reboot is the least
portable of all — it has no meaning in many container init systems
and does not fire in environments that never "reboot" the cron daemon.
If portability matters, spell out the five fields.
Timezone and DST
Cron evaluates expressions in the daemon's timezone, which is normally
the system timezone, not UTC unless the system is set to UTC. A
job written as 0 9 * * * runs at 9am local time on whatever machine
the daemon happens to be on. Move the workload to a host in another
region and the schedule shifts silently.
Daylight saving transitions are the sharp edge. On the spring-forward night, local time jumps from 01:59 to 03:00; a job scheduled for 02:30 does not run that day because 02:30 never occurs. On the fall-back night, 02:30 happens twice, and depending on the cron implementation the job may run twice or may be deduplicated. Vixie cron has special-case logic to fire jobs skipped by spring-forward and to avoid double-firing on fall-back for jobs in the gap, but the behavior is implementation-specific and not something to rely on for financial or billing jobs.
The defensive posture is to run the cron daemon in UTC and do any
local-time conversion inside the job. Jobs scheduled in UTC never hit
a skipped or doubled hour because UTC has no DST. Most modern
schedulers (systemd timers with OnCalendar, Kubernetes CronJobs)
let you set the timezone explicitly; standard crond inherits the
system TZ and is best pinned to UTC at the OS level.
The seconds-field trap
Standard cron has no seconds field. The smallest unit it schedules is one minute. This catches people coming from other ecosystems, because several popular non-Unix schedulers add a leading sixth field for seconds:
- Quartz (Java) uses a 6- or 7-field format:
seconds minutes hours day-of-month month day-of-week [year]. - node-cron and several other language libraries accept an optional leading seconds field, so they take both 5- and 6-field expressions.
- Spring's
@Scheduled(cron=...)uses Quartz-style 6-field syntax.
The portability trap is that a 6-field expression copied from a Quartz example into a Unix crontab shifts every field left by one. `0 0 12 *
- ?
(Quartz noon daily) pasted into Linux crontab is read as minute 0, hour 0, day-of-month 12, month, day-of-week, with a stray?that standard cron does not even understand. Quartz also uses?andL/W/#` modifiers that standard cron has no concept of. Always confirm whether the target runtime expects 5 or 6 fields before copying an expression between systems.
Sanity-checking an expression
Before committing a cron line, confirm three things: the field count matches the runtime (5 for Unix cron, 6 for Quartz/node-cron), neither day field is accidentally restricted when you mean AND, and the next few fire times land where you expect. Reading the next run times out loud catches most mistakes — an expression that "looks daily" but fires hourly is obvious the moment you see the schedule expanded.
Our Cron Parser expands an expression into plain English and lists the upcoming run times, which is the fastest way to verify a line before it ships — especially for the OR quirk, where the expanded schedule makes the union behavior immediately visible.