Skip to main content
ToolsBay

DEVELOPER TOOLS

Cron Expression Syntax, and the Rules That Misfire Jobs

8 min read · ToolsBay editorial · Published · Updated

Just want to do it now?

Build and read cron schedules without memorising the syntax.

Open Cron Expression Generator

A cron expression is five fields and about six punctuation rules. The whole grammar fits on a postcard, and almost nobody gets bitten by the grammar.

What bites is an expression that parses cleanly and means something other than what you meant. Or one that means a schedule in crontab and a different schedule in Quartz. Or one that is entirely correct and still never runs, because the shell cron handed it had no PATH. Here is the syntax, and then the places it behaves in ways the syntax does not hint at.

The five fields

 ┌───────────── minute        (0-59)
 │ ┌─────────── hour          (0-23)
 │ │ ┌───────── day of month  (1-31)
 │ │ │ ┌─────── month         (1-12 or JAN-DEC)
 │ │ │ │ ┌───── day of week   (0-7 or SUN-SAT)
 │ │ │ │ │
 0 5 * * 1  /usr/local/bin/backup.sh

That reads as 05:00 every Monday. An asterisk means every value of that field, not "any" or "unset", so * * * * * is 1,440 runs a day.

Day of week takes 0 through 7, where both 0 and 7 are Sunday. That is a Vixie cron convenience; POSIX defines only 0 through 6. Month and day of week also accept three-letter names, but numbers are the portable choice: the original Vixie manual says ranges and lists of names are not allowed, and although several current implementations do accept MON-FRI, 1-5 works everywhere.

Lists, ranges and steps

A comma makes a list, a hyphen makes a range, a slash makes a step, and they combine. 0 9-17/2 * * 1-5 is every two hours from 09:00 to 17:00 on weekdays — 9, 11, 13, 15 and 17.

The step does not wrap

A step counts from the start of its field and stops at the end of it. It does not carry over.

Put */7 in the minute field and you get 0, 7, 14, 21, 28, 35, 42, 49, 56 — nine runs an hour, eight of them seven minutes apart and the last one only four minutes before the top of the next hour. Put 0 */5 * * * in and you get 00:00, 05:00, 10:00, 15:00, 20:00, and then a four-hour gap to midnight. If that job trims a queue every five hours, it has a four-hour hole in it once a day.

A step is only even when it divides its field: 60 by 1, 2, 3, 4, 5, 6, 10, 12, 15, 20 or 30, and 24 by 1, 2, 3, 4, 6, 8 or 12. Anything else leaves a short interval at the wrap.

A step on a bare number, like 5/10, is also not consistent: some implementations reject it, some read it as 5 through the top of the field. Write 5-59/10 instead.

Day of month and day of week are OR'd

This is the single most misread rule in cron, and it is in POSIX: when both the day-of-month and the day-of-week field are restricted, the job runs when either matches. Not both.

So 0 0 13 * 5 is not "midnight on Friday the 13th". There are three Friday the 13ths in 2026 — February, March and November. That expression fires 61 times during 2026: every 13th of the month, plus every Friday.

If either day field is *, the surprise disappears: 0 0 13 * * really is the 13th, and 0 0 * * 5 really is every Friday. The trap only opens when you fill in both.

There is no cron syntax for AND. To get it, leave one field as * and check the other condition in the command itself:

0 0 13 * * [ "$(date +\%u)" = 5 ] && /usr/local/bin/audit.sh

Note the backslash in front of the percent sign. It is not decoration.

A percent sign in a crontab is not a percent sign

From crontab(5): an unescaped % in the command is converted to a newline, and everything after the first one is passed to the command as standard input. That rule turns an ordinary line into rubble.

0 3 * * * pg_dump app > /backups/app-$(date +%F).sql

Cron does not run that. It runs pg_dump app > /backups/app-$(date +, with F).sql arriving on standard input — an unterminated command substitution, so the shell reports a syntax error every night at three, into mail that nobody reads. Escape every percent as \%, or do the obvious thing and move the command into a script file, where the rule does not apply.

The extensions people assume are portable

Three families of syntax are not standard cron, and all three travel badly.

  • The at-sign shorthands. @daily, @hourly, @weekly, @monthly, @yearly and @reboot are Vixie extensions, not POSIX. @daily is just 0 0 * * *. @reboot means nothing to anything that is not a daemon on a machine that boots, which is why hosted schedulers do not have it.
  • A seconds field. Quartz, Spring's @Scheduled and node-cron accept six fields with seconds first. Unix cron's smallest interval is one minute, full stop.
  • The Quartz specials. L for the last day of the month, 6#3 for the third Friday, W for the nearest weekday, ? for "no specific value". Vixie cron has none of them, and will not tell you so.

That last one is worth demonstrating, because of how quietly it fails. crontab reads five time fields and treats the rest of the line as the command. Paste a six-field Quartz expression in and nothing errors:

0 0 12 * * ?

In Quartz that is noon every day. In a Unix crontab it is minute 0, hour 0, day-of-month 12, and a command named ? — midnight on the 12th of each month, executing something that does not exist. It installs without complaint.

The numbering shifts too. Quartz counts day of week 1 to 7 with Sunday as 1, so 6 is Friday there and Saturday in cron. AWS EventBridge rules take six fields with a year on the end, require ? in one of the two day fields because * may not appear in both, number the week the Quartz way, and are evaluated in UTC. GitHub Actions takes plain five-field cron, always in UTC, no more often than every five minutes, and its own documentation warns that scheduled runs can be delayed when the platform is busy.

Two broken hours a year

Cron uses the machine's local time. If that machine keeps a zone with daylight saving, one day a year has no 02:30 on it and one day has two.

crontab(5) on Vixie cron and cronie describes what those implementations do about it. Jobs with a wildcard in the hour or minute field simply follow the clock. Jobs at a fixed time that a forward jump skipped are run shortly after the change, and on a backward jump care is taken not to run them twice — for shifts of less than three hours, which covers ordinary daylight saving. So 30 2 * * * is handled on a normal Linux box. */10 * * * * is not compensated at all: it loses six runs in spring and gains six in autumn.

Nothing outside those implementations promises any of that, which is why the boring rule survives: keep servers on UTC, and where you cannot, do not schedule anything between midnight and 03:00 local time. Where a scheduler lets you name a zone — Kubernetes CronJob has spec.timeZone — give it an IANA name like Europe/London rather than a fixed offset. An offset cannot know that the offset changes.

Testing one before it costs you a night

Three checks, and they fail in three different ways.

Does the expression mean what you think? Read it back in words rather than re-reading the digits you just typed. The cron expression generator checks each field against its own range, catches a range that starts after it ends, and describes the whole expression in English. An off-by-one in the day-of-week field is the error this catches.

Will the command run in cron's environment? Cron does not source .bashrc or .profile. Under Vixie cron and cronie the job gets SHELL=/bin/sh, PATH=/usr/bin:/bin, HOME from /etc/passwd, and very little else — so nothing installed by nvm, pyenv, rbenv or Homebrew is on the path. Test it the way cron will run it:

env -i SHELL=/bin/sh PATH=/usr/bin:/bin HOME="$HOME" /bin/sh -c '/usr/local/bin/backup.sh'

If that fails at your own prompt, it will fail at 03:00. Absolute paths fix most of it.

Where does the output go? Cron mails stdout and stderr to the crontab's owner. On a host with no mail transfer agent that mail is discarded, so a job that has failed every night for a month looks exactly like a job that works. Redirect it yourself with >> /var/log/backup.log 2>&1. When you then read epoch values out of that log, convert them properly rather than eyeballing whether the number is seconds or milliseconds.

One thing cron will never do for you: it starts a job at its scheduled time whether or not the previous run has finished. If a job can outlive its interval, wrap it — flock -n /tmp/backup.lock /usr/local/bin/backup.sh exits immediately when the lock is held, instead of stacking a second copy on the first.

The five fields are an afternoon's work. The OR rule, the unescaped percent sign, the empty PATH and the sixth field that quietly becomes a command are what actually cost the night.

Tools covered in this guide