Cron syntax, explained properly
Everything the man page assumes you already know: the five fields, the special characters, the timezone traps, and the failure modes that only show up in production. Ten minutes here saves you the 2am incident later.
The five fields
┌───────────── minute (0–59)
│ ┌─────────── hour (0–23)
│ │ ┌───────── day of month (1–31)
│ │ │ ┌─────── month (1–12 or JAN–DEC)
│ │ │ │ ┌───── day of week (0–6, 0 = Sunday)
│ │ │ │ │
* * * * * command-to-run
Each field accepts a value, a range, a list, or a step — and * means “every”.
Reading an expression is just filling in the sentence: “run at minute M of hour
H on day D…”. A few worked examples:
*/5 * * * *— every 5 minutes: minute steps by 5, everything else is “every”.0 9 * * 1-5— weekdays at 9am: minute 0, hour 9, Monday through Friday.0 0 1 * *— the 1st of every month at midnight.
Minute and hour
The workhorses. 0–59 and 0–23, and this is where steps live:
*/15 in the minute field is every quarter
hour, */2 in the hour field is every other
hour. One non-obvious detail: steps count from the start of the field’s range,
not from when you installed the job. */5 fires at :00, :05, :10 — never at
:03, :08, :13.
Day of month and month
1–31 and 1–12 (month names JAN–DEC work in most
implementations). The trap: days 29–31 don’t exist in every month. A job on
day 31 runs seven times a year; day 30 skips February; and standard cron has no
“last day of month” — see the workaround on the
first-of-month page.
Day of week
0–6 with 0 = Sunday (names SUN–SAT also work).
Vixie cron additionally accepts 7 for Sunday; POSIX and BusyBox don’t — write
0 and it works everywhere. And if you ever port an expression to Quartz
(Java), beware: there 1 = Sunday, so Linux 1-5 (Mon–Fri) becomes
Sun–Thu. That single off-by-one has shipped a lot of Saturday emails.
Special characters, from everywhere to exotic
| Syntax | Meaning | Works in |
|---|---|---|
* | every value | everywhere |
, | list — 1,4,7,10 = quarterly | everywhere |
- | range — 1-5 = Mon–Fri | everywhere |
/ | step — */10 = every 10 | everywhere |
@daily, @hourly, @reboot | shorthands | Vixie/cronie, not POSIX |
L, W, # | last day, nearest weekday, “2nd Tuesday” | Quartz & some clouds only |
? | “no specific value” for day fields | Quartz only |
Rule of thumb: stick to * , - / and your expression is portable across
crontab, GitHub Actions,
Kubernetes, and almost everything else. The moment you use
L or ?, you’ve married one scheduler.
The OR quirk nobody expects
When both day-of-month and day-of-week are restricted, classic cron runs the job
when either matches — not both. 0 0 13 * 5 doesn’t mean
“Friday the 13th”; it means “every 13th and every Friday”. To get the intersection,
restrict one field in the expression and check the other in the command:
0 0 13 * * [ "$(date +\%u)" = 5 ] && run-spooky-job.
Timezones and DST: where cron jobs go to die
An expression has no timezone of its own — it fires in the scheduler’s timezone, and every platform picks a different one:
- crontab: the server’s local time (check with
timedatectl; some crons honor aCRON_TZ=line). - GitHub Actions: always UTC, no setting.
0 9 * * *is 5pm in Taipei. - Kubernetes: UTC unless you set
spec.timeZone(1.27+).
Daylight saving adds the sharp edge: in a TZ-aware scheduler, a job at 02:30 local can run twice on the fall-back night and never on the spring-forward night. Two defenses: schedule money-touching jobs in UTC, or keep wall-clock times outside 01:00–03:00. (Full background: crontab(5).)
The three production failure modes
1. Overlap
Cron starts your job on schedule whether or not the previous run finished. A
every-minute job that occasionally takes 90 seconds will
stack copies until the box falls over. Fix: flock turns overlap into a skipped
run — * * * * * flock -n /tmp/job.lock your-command.
2. The thundering herd
Everyone schedules at :00, midnight most of all. Your DB, your API providers, and every
other tenant on the host spike together. If the exact minute doesn’t matter, don’t use
minute 0 — 17 * * * * is the same hourly job without the queueing, and
0 3 * * * beats midnight for nightly
work.
3. Silent death
Cron has no retries, no alerting, and emails its errors to a local mailbox nobody reads. A weekly job that fails waits seven days for its next chance — and a yearly one fails years after its author left. The fix is a dead-man’s-switch monitor: the job pings a URL on success, and you get alerted when the ping doesn’t arrive.
Will you know if this job silently fails?
Cron jobs fail quietly — a server reboots, a path changes, or an error code is ignored — and nobody notices until the data is missing. A cron monitor (a dead-man’s-switch) alerts you when a scheduled job does not check in on time.
Monitor your cron jobs with UptimeRobot →
Disclosure: this is an affiliate link — we may earn a commission if you sign up, at no extra cost to you.
Grab a ready-made expression
Or skip the theory — use the interactive generator & explainer, or copy one of these: