Cron Expression Syntax Explained: Fields, Special Characters and Real Schedules
A cron expression packs a recurring schedule into five space-separated fields. The notation is compact enough that '0 0 13 * 5' fits on a line and ambiguous enough that most people misread it (it does not mean Friday the 13th). This guide goes through the fields, the four special characters, the rule that governs the two day fields, and the platform differences that turn a correct expression into a wrong one. Every plain-English translation below was produced by cronstrue 3.14, and every next-run list was computed with croner 10.
In short
A cron expression is five space-separated fields — minute, hour, day of month, month and day of week — read as filters on the current time, not intervals. The four special characters are * (every value), , (a list), - (a range) and / (a step). When both day fields are restricted, classic cron treats them as an OR, not an AND.
The five fields and their ranges
Fields are read left to right: minute (0–59), hour (0–23), day of month (1–31), month (1–12), day of week (0–6, where 0 is Sunday). Most implementations also accept 7 for Sunday and three-letter names (JAN–DEC, SUN–SAT) regardless of case. A value must fall inside its field's range: '60 * * * *' and '* 24 * * *' are syntax errors, not 'next hour'.
Each field is a filter on the current time, not an interval. '30 2 * * *' means 'when the minute is 30 and the hour is 2', which happens once a day at 02:30; it does not mean 'every two hours and thirty minutes'. The hour field is 24-hour, so 14 is 2 PM. Holding on to the filter model explains almost every surprise that follows.
# ┌───────────── minute (0–59)
# │ ┌─────────── hour (0–23)
# │ │ ┌───────── day of month (1–31)
# │ │ │ ┌─────── month (1–12, or JAN–DEC)
# │ │ │ │ ┌───── day of week (0–6, Sunday = 0; 7 is also Sunday; or SUN–SAT)
# │ │ │ │ │
# * * * * * command-to-run
30 2 * * * # At 02:30 AM
5 4 * * sun # At 04:05 AM, only on Sunday
0 0 1 1 * # At 12:00 AM, on day 1 of the month, only in JanuaryThe special characters: asterisk, comma, hyphen, slash
An asterisk means every value the field allows. A comma separates a list ('9,12,15'), a hyphen defines an inclusive range ('9-17'), and a slash sets a step. '*/15' in the minute field is minutes 0, 15, 30 and 45; '0-20/2' in the hour field is every second hour from 0 through 20. Steps count from the start of the range, not from the job's previous run: '*/7 * * * *' fires at :00, :07, :14 ... :56 and then at :00 again, so the gap across the hour boundary is four minutes, not seven.
The forms combine freely. '0 9-17/2,20 * * 1-5' is minute 0 of hours 9, 11, 13, 15, 17 and 20 on weekdays. Classic cron only allows a step after an asterisk or a range; a step after a bare number, as in Quartz's '1/5' ('starting at 1, every 5'), is not portable.
| Character | Meaning | Example |
|---|---|---|
| * | Every value the field allows | * * * * * → every minute |
| , | A list of values | 9,12,15 → at 9, 12 and 15 |
| - | An inclusive range | 9-17 → 9 through 17 |
| / | A step, counted from the start of the range | */15 → 0, 15, 30, 45 |
*/15 * * * * # Every 15 minutes
0 */6 * * * # On the hour, every 6 hours (00:00, 06:00, 12:00, 18:00)
0 9,12,15 * * * # At 09:00 AM, 12:00 PM and 03:00 PM
0 9-17 * * 1-5 # Every hour, between 09:00 AM and 05:00 PM, Monday through Friday
23 0-20/2 * * * # At 23 minutes past the hour, every 2 hours, between 12:00 AM and 08:59 PM
1-59/2 * * * * # Every 2 minutes, minutes 1 through 59 past the hour
0 0 1 */3 * # At 12:00 AM, on day 1 of the month, every 3 months
0 9-17/2,20 * * 1-5 # On the hour, every 2 hours, between 09:00 AM and 05:00 PM and at 08:00 PM, Mon–FriDay of month versus day of week: the OR rule
Day of month and day of week are the only two fields that describe the same thing, and cron has to decide what to do when both are set. When both are restricted (neither is an asterisk), classic cron runs the job when either one matches: an OR, not an AND. '0 0 13 * 5' therefore fires on the 13th of every month and on every Friday. Computed from 1 September 2026, its next runs are 4, 11, 13, 18 and 25 September: four Fridays plus a Sunday the 13th.
This is why the intuitive 'last Friday of the month' expression '0 18 25-31 * 5' is wrong: it fires every Friday and every day from the 25th to the 31st. When one field is an asterisk the rule collapses to the other field, so '0 9 * * 1-5' is simply weekdays. Vixie cron and cronie add a further quirk: a field that starts with an asterisk (such as */2) counts as unrestricted for this rule, which switches the combination to AND. Some libraries deliberately use AND semantics (croner with legacyMode: false, for instance). The safe practice is to restrict only one of the two fields.
const { Cron } = require("croner");
// Classic (OR) semantics — the default, matching crontab
new Cron("0 0 13 * 5", { timezone: "UTC" }).nextRuns(5, "2026-09-01T00:00:00Z")
// 2026-09-04 (Fri) 2026-09-11 (Fri) 2026-09-13 (Sun, the 13th) 2026-09-18 (Fri) 2026-09-25 (Fri)
// AND semantics — Friday the 13th only
new Cron("0 0 13 * 5", { timezone: "UTC", legacyMode: false }).nextRuns(3, "2026-09-01T00:00:00Z")
// 2026-11-13 2027-08-13 2028-10-13Common schedules in plain English (and the ones five fields cannot express)
The list below covers most real jobs, with the description cronstrue produces for each. Note the difference between 'every hour between 9 and 17' (minute 0 only, so the last run is 17:00) and 'every 5 minutes between 9 and 17', which runs until 17:55 because the hour filter accepts every minute of hour 17.
Two popular requests do not fit five fields: 'the last day of the month' (no field can say 28, 29, 30 or 31 depending on the month) and 'the last Friday' or 'the first Monday'. Quartz added L (last) and # (nth weekday) for exactly this: '0 0 L * *' is the last day of the month, '5L' in the day-of-week field is the last Friday, and '1#1' is the first Monday. Linux crontab rejects all three, as do most five-field parsers. The plain-cron workaround is to run on every candidate day and let the command check the date. In a crontab the percent sign must be written as \% because a bare % is converted to a newline.
*/15 * * * * Every 15 minutes
0 9 * * 1-5 At 09:00 AM, Monday through Friday
0 0 1 * * At 12:00 AM, on day 1 of the month
0 0 1,15 * * At 12:00 AM, on day 1 and 15 of the month
0 0 * * 0 At 12:00 AM, only on Sunday
0 3 * * 6 At 03:00 AM, only on Saturday
0 0 * * 1,3,5 At 12:00 AM, only on Monday, Wednesday, and Friday
*/5 9-17 * * 1-5 Every 5 minutes, between 09:00 AM and 05:59 PM, Monday through Friday
0 9 * 6-8 1-5 At 09:00 AM, Monday through Friday, June through August
0 0 15 JAN,JUL * At 12:00 AM, on day 15 of the month, only in January and July
# Quartz-only modifiers — crontab rejects these
0 0 L * * At 12:00 AM, on the last day of the month
0 18 * * 5L At 06:00 PM, on the last Friday of the month
0 12 * * 1#1 At 12:00 PM, on the first Monday of the month
# Plain-cron workarounds: run on every candidate day, let the command decide
0 18 * * 5 [ "$(date +\%d)" -ge 25 ] && /path/to/last-friday-job
0 0 28-31 * * [ "$(date -d tomorrow +\%d)" = "01" ] && /path/to/last-day-job # GNU dateTimezones and daylight saving time
Cron evaluates the fields against a wall clock, and which clock is the first thing to check. crond uses the system timezone; containers and CI runners are usually UTC; Kubernetes CronJobs run on the controller's clock unless spec.timeZone is set; GitHub Actions schedules are always UTC. '0 9 * * *' on a UTC host is 04:00 in New York in winter and 05:00 in summer, so a job tuned to run 'before the office opens' silently drifts by an hour twice a year. cronie (Fedora, RHEL, Arch) honours a CRON_TZ=Europe/Berlin line in the crontab, which pins the wall clock for the lines that follow; the older Vixie-derived cron on Debian and Ubuntu does not, so check crontab(5) on the target host.
Daylight saving transitions create times that do not exist and times that occur twice. In America/New_York on 8 March 2026 the clock jumps from 01:59:59 to 03:00:00, so '30 2 * * *' has no 02:30 to fire at; on 1 November 2026, 01:30 happens twice. Vixie cron, cronie and croner all handle this by running the job once: croner fires the skipped 02:30 job at 03:30 on the transition day, and fires the repeated 01:30 job only on its first occurrence. Simpler schedulers may skip the job or run it twice. Scheduling outside the 01:00–03:00 local window, or simply in UTC, sidesteps the problem entirely.
// croner 10 — "30 2 * * *" in America/New_York, next runs after 2026-03-07
// 2026-03-08 03:30 EDT (02:30 did not exist; the job ran once, an hour late)
// 2026-03-09 02:30 EDT
// 2026-03-10 02:30 EDT
// "30 1 * * *" across the fall-back on 2026-11-01: fires once, at the first 01:30
// 2026-11-01 01:30 EDT (05:30Z) 2026-11-02 01:30 EST (06:30Z)
// "0 9 * * *" on a UTC host, as seen from New York
// 2026-03-01 → 04:00 local 2026-03-10 → 05:00 local
// crontab on cronie: pin the wall clock for the lines below
// CRON_TZ=Europe/Berlin
// 0 9 * * 1-5 /usr/local/bin/morning-reportFive fields, six fields, Quartz and POSIX: which dialect are you writing?
POSIX defines only the five fields with asterisks, numbers, ranges and lists. Steps (/), month and weekday names, 7 for Sunday, and the @hourly, @daily, @weekly, @monthly, @yearly and @reboot nicknames are Vixie cron extensions: nearly universal on Linux and BSD, but not guaranteed by every scheduler that says it 'supports cron syntax'. If an expression has to be portable, spell a step out as a list.
Several platforms add fields. Quartz, Spring's @Scheduled and many application libraries put a seconds field first, so '0 0 12 * * ?' is noon daily; AWS EventBridge appends a year field instead. Pasting a six-field Quartz expression into a crontab does not fail loudly: the sixth token becomes the start of the command. Quartz also numbers weekdays 1–7 with 1 = Sunday (use SUN–SAT names to stay safe), requires a ? in either day field because it never applies the OR rule, and adds L, W and # modifiers. systemd timers use a different calendar syntax altogether (OnCalendar=Mon..Fri 09:00), so a schedule has to be translated rather than copied.
# Quartz (Java, Spring): seconds first, ? required in one day field, optional year
0 15 10 ? * MON-FRI At 10:15 AM, Monday through Friday
0 0/5 14 * * ? Every 5 minutes, between 02:00 PM and 02:59 PM
0 0 12 1/5 * ? At 12:00 PM, every 5 days in a month (starting on the 1st)
0 30 8 ? * 6L At 08:30 AM, on the last Saturday of the month
0 0 0 LW * ? At 12:00 AM, on the last weekday of the month
0 0 0 15W * ? At 12:00 AM, on the weekday nearest day 15 of the month
# Vixie cron nicknames (not POSIX)
@hourly = 0 * * * *
@daily = 0 0 * * *
@weekly = 0 0 * * 0
@monthly = 0 0 1 * *
@yearly = 0 0 1 1 *
@reboot Run once, at startupHow to test a schedule before it runs
Read the expression back in English first. A translator such as cronstrue turns '0 0 13 * 5' into 'At 12:00 AM, on day 13 of the month, and on Friday', and that 'and' is the OR rule showing through. Then compute concrete next-run times in the target timezone: five or ten timestamps make DST shifts and OR-rule surprises visible in a way a sentence does not. Both steps take a minute with cronstrue and croner in Node, or with our cron expression generator, which does the same on a single page.
Finally, test on the host. Install the crontab, tail the log (journalctl -u cron, or /var/log/syslog) and check that PATH inside cron is what the script expects. cron runs commands with a minimal environment, and 'works in my shell, silent in cron' is nearly always a PATH or working-directory problem rather than a schedule problem. Redirect output to a file with >> /var/log/job.log 2>&1 so that a failing job leaves evidence instead of a bounced local mail.
// npm i cronstrue croner
const cronstrue = require("cronstrue");
const { Cron } = require("croner");
const expr = "0 9 * * 1-5";
console.log(cronstrue.toString(expr));
// At 09:00 AM, Monday through Friday
const next = new Cron(expr, { timezone: "Europe/Berlin" }).nextRuns(3, "2026-09-10T12:00:00Z");
console.log(next.map(d => d.toISOString()));
// [ "2026-09-11T07:00:00.000Z", "2026-09-14T07:00:00.000Z", "2026-09-15T07:00:00.000Z" ]
// 09:00 Berlin time is 07:00 UTC in September — the shift is visible immediatelyWorked examples: one page per schedule
Each schedule below has its own page: the expression, a field-by-field breakdown, the next five run times in your timezone, and the mistakes that particular schedule invites.
* * * * *Cron Every Minute*/5 * * * *Cron Every 5 Minutes*/10 * * * * *Cron Every 10 Seconds*/15 * * * *Cron Every 15 Minutes*/30 * * * *Cron Every 30 Minutes0 * * * *Cron Every Hour0 */2 * * *Cron Every 2 Hours0 */6 * * *Cron Every 6 Hours0 0 * * *Cron Every Day at Midnight0 2 * * *Cron Every Night at 2 AM0 9 * * *Cron Every Day at 9 AM0 12 * * *Cron Every Day at Noon0 0,12 * * *Cron Twice a Day0 0 */2 * *Cron Every Other Day0 9 * * 1-5Cron Every Weekday at 9 AM30 8 * * 1-5Cron at 8:30 AM Monday to Friday0 9-17 * * 1-5Cron Every Hour During Work Hours*/15 9-17 * * 1-5Cron Business Hours Every 15 Minutes*/5 9-17 * * *Cron Every 5 Minutes Between 9 and 50 8 * * 1Cron Every Monday at 8 AM0 17 * * 5Cron Every Friday at 5 PM0 0 * * 6Cron Every Saturday0 0 * * 0Cron Every Sunday at Midnight0 0 * * 6,0Cron Every Weekend0 0 1 * *Cron First Day of the Month0 0 28-31 * *Cron Last Day of the Month0 9 8-14 * 2Cron Every Second Tuesday of the Month0 0 1 */3 *Cron Every 3 Months0 0 1 1,4,7,10 *Cron Every Quarter0 0 1 1 *Cron Every Year on January 1
Sources
Frequently asked questions
Does '0 0 13 * 5' run on Friday the 13th?
No. When both day fields are restricted, classic cron runs the job when either matches, so it fires on every 13th and on every Friday. To get only Friday the 13th, restrict one field and check the other in the command: 0 0 13 * * [ "$(date +\%u)" = 5 ] && job.
How do I run a cron job every 30 seconds?
Classic cron's resolution is one minute. The usual workaround is two crontab lines, the second prefixed with sleep 30. If you need sub-minute precision, use a scheduler with a seconds field (Quartz, croner, node-cron) or a systemd timer with OnUnitActiveSec=30s.
What does the question mark mean in a cron expression?
It is Quartz syntax for 'no specific value' and is required in exactly one of the day-of-month and day-of-week fields, because Quartz refuses to combine them. Linux crontab does not accept ?; use an asterisk there instead.
Why does my cron job run at the wrong hour?
Almost always the timezone: the daemon evaluates the fields in the host's timezone, which is UTC in most containers and CI systems. Either convert the schedule to UTC, set CRON_TZ on a daemon that supports it, or set spec.timeZone on a Kubernetes CronJob. Remember that a UTC schedule moves by an hour in local terms when DST starts and ends.
Compose an expression with dropdowns or type it directly, read it back in plain English, and preview the next ten run times in your timezone.
Build a scheduleKeep working
Related tools
Cron Expression Generator
Five dropdowns in, plain English out — with the next ten run times in your local timezone so you ship the schedule you actually meant.
Epoch Converter
Paste a timestamp in seconds or milliseconds — or a date string — and get every form of the same instant at once, without ever consulting the clock.