Skip to content
d.devtul.fun
中文
DevOps · 2026-09-22

Mastering Cron Expressions: A Complete Reference Guide

Cron has been the default job scheduler on Unix systems for four decades, and it is still everywhere: backup scripts, report generation, cache warming, log rotation. Yet the expression syntax remains one of those things most engineers copy from a snippet without fully internalising. This guide is the reference I wish I had kept open beside my terminal.

The five fields, in order

A standard cron expression is five fields separated by whitespace:

┌── minute          (0–59)
│ ┌── hour          (0–23)
│ │ ┌── day of month (1–31)
│ │ │ ┌── month      (1–12 or JAN–DEC)
│ │ │ │ ┌── day of week (0–6, 0 and 7 = Sunday)
│ │ │ │ │
* * * * *

Read it left to right as "at these minutes, on these hours, of these days, in these months, on these weekdays". The fields combine with AND by default: a moment triggers the job only when every field matches it.

Allowed values per field

FieldAllowedWorth knowing
minute0–59—
hour0–230 is midnight, 23 is 23:00
day of month1–31Clamped to the real length of the month
month1–12Names JAN–DEC also accepted
day of week0–60 and 7 both mean Sunday; SUN–SAT accepted

What each special character means

SymbolMeaningExample
*Every possible value* in minute = every minute
,A list1,15 = the 1st and the 15th
-An inclusive range9-17 = 9 through 17
/A step / interval*/15 = every 15 units
?"No specific value" (Quartz only)Used instead of * in one date field
LLast (Quartz only)5L = last Friday of the month
WNearest weekday (Quartz only)15W = closest weekday to the 15th
#Nth weekday (Quartz only)2#1 = first Monday

The last four — ?, L, W, # — do not exist in standard Vixie cron. They belong to the Quartz / Spring scheduler family and fail or behave oddly if you paste them into a plain /etc/cron.d file. Keep that line drawn before you borrow an expression from a Java tutorial.

The OR trap: day-of-month vs day-of-week

This is the single most misunderstood rule in cron. When both the day-of-month and the day-of-week fields are restricted — neither is * — most implementations, including Vixie cron and every mainstream Linux distribution, treat them as an OR, not an AND.

0 0 1 * 1

Intuition reads that as "midnight on the 1st, but only when it is also a Monday". The real meaning is "the 1st of the month, or any Monday". To express the AND you have to leave one field as * and filter the rest inside your script:

# runs only on the 1st AND when it is a Monday
0 0 1 * *  [ "$(date +%u)" = "1" ] && /opt/report.sh

This trap catches a fresh batch of people every year, especially on billing and reporting jobs where the exact date matters.

A reference table of common expressions

ExpressionFires
*/5 * * * *Every 5 minutes
0 * * * *On the hour, every hour
0 3 * * *Daily at 03:00
30 9 * * 1-5Weekdays at 09:30
0 0 1 * *The 1st of every month at midnight
0 0 * * 1Every Monday at midnight
0 0 1 1,4,7,10 *First day of every quarter
0 2 * * 6Every Saturday at 02:00

Preset macros

Many cron daemons accept these shortcuts instead of five fields:

@yearly   (or @annually)   0 0 1 1 *
@monthly                0 0 1 * *
@weekly                 0 0 * * 0
@daily    (or @midnight)   0 0 * * *
@hourly                 0 * * * *
@reboot                 at boot time

Macros are convenient but not portable to every platform, so check your daemon's manual before relying on them in production.

Time zones: server time vs business time

Cron evaluates against the server's local time zone. The same file deployed to two regions can fire hours apart. On a daylight-saving transition day, jobs scheduled inside the skipped-forward hour may never run, and jobs inside the repeated hour may run twice.

If the timing is business-critical, standardise your hosts on UTC and do the offset arithmetic yourself, or move to a scheduler that names a time zone explicitly (systemd timers take a Persistent=true and a Timezone= option; Kubernetes CronJobs run in UTC unless you handle it in the container).

Standard cron vs Quartz / Spring six-field

The Quartz and Spring @Scheduled schedulers add a seconds field at the front, making six fields:

┌── second (0–59)
│ ┌── minute (0–59)
│ │ ┌── hour (0–23)
│ │ │ ┌── day of month (1–31)
│ │ │ │ ┌── month (1–12)
│ │ │ │ │ ┌── day of week (0–6)
│ │ │ │ │ │
* * * * * *

So a Spring expression like 0 0 3 * * * means 03:00:00, whereas a system crontab 0 3 * * * means 03:00. Reusing a five-field expression in a six-field slot shifts every value one column to the right and silently changes the schedule. The seconds field also enables ?, L, W and #, which plain cron rejects.

Missed runs and re-entrancy

If the machine is down when a job is due, standard cron does not run it afterwards — the moment is simply missed. (systemd timers with Persistent=true behave differently and catch up on the next boot.) Because there is no lock by default, a job that runs longer than its interval can overlap itself. Guard long or frequent jobs with a lock file or flock:

* * * * *  /usr/bin/flock -n /tmp/job.lock /opt/job.sh

The landmine: expressions that never fire

Because day-of-month and day-of-week are OR'd together, some expressions can never match:

0 0 31 2 *

February never has 31 days, so this job is dead on arrival — yet it parses cleanly and sits there looking innocent. Another silent killer is a range that crosses the month boundary in a way no date satisfies. Always sanity-check a new expression against its next several trigger times before shipping it.

Verify before you deploy

The quickest check is to read back what the expression actually means and list its next runs. The cron expression tool turns your string into a plain sentence and shows the upcoming trigger times, so a landmine like 0 0 31 2 * or an unintended OR is exposed before it costs you a missed report.

Keep reading