Crontab syntax in one page: the five fields, the special characters * , - /, the @daily shortcuts, the day-of-month OR day-of-week trap, the percent sign, and a table of ready-to-copy schedules.
Crontab syntax looks simple: five fields and a command. Most broken schedules still come from those five fields, because a few rules are not what you would guess. This page covers every field, every special character, the shortcuts like @daily, and the two traps that catch experienced admins: the day-of-month and day-of-week combination, and the percent sign.
This is the third post in our Cron in production series. The first one covered why a job does not run, the second one where to find the evidence. If you only want to check one expression, paste it into the cron expression parser: it splits it into fields and shows the next five runs in your timezone.
A line in a user crontab (crontab -e) has five time fields followed by the command:
┌───────────── 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 and 7 are both Sunday)
│ │ │ │ │
30 2 * * 1-5 /opt/scripts/report.shThat line runs report.sh at 02:30 on weekdays. The time is the time of the machine (or the CRON_TZ variable, where the cron implementation supports it), not UTC and not your laptop’s time zone.
A job runs when the current minute matches all fields, with one exception covered below. Cron checks once a minute, so the smallest interval is one minute. There is no seconds field in standard cron.
| Character | Meaning | Example | Runs |
|---|---|---|---|
* | Every value | * * * * * | Every minute |
, | List | 0 8,12,18 * * * | At 08:00, 12:00 and 18:00 |
- | Range, inclusive | 0 9-17 * * * | Every hour from 09:00 to 17:00 |
/ | Step | */15 * * * * | At minutes 0, 15, 30 and 45 |
a-b/n | Step within a range | 0 8-18/2 * * * | 08:00, 10:00, … 18:00 |
| names | Months and days | 0 0 * jan,jul mon | Midnight on Mondays in January and July |
You can combine them in one field: 0,30 9-17 * * 1-5 runs every half hour during office hours on weekdays.
*/n means “every value divisible by n, counting from the start of the field”. It does not mean “every n units from when I saved the crontab”. Two consequences:
*/7 * * * * runs at minutes 0, 7, 14, … 49, 56 and then again at 0. The gap between 56 and 0 is four minutes, not seven. Steps that do not divide 60 (or 24 for hours) are uneven at the wrap-around.*/90 * * * * does not mean every 90 minutes. The minute field only goes to 59, so it runs once an hour, at minute 0. For “every 90 minutes” you need two lines: 0 0-21/3 * * * and 30 1-22/3 * * *.To start a step somewhere else, use a range: 5-59/10 * * * * runs at 5, 15, 25, 35, 45 and 55.
mon, MON and Mon all work. Ranges of names such as mon-fri work in Vixie cron and cronie (Debian, Ubuntu, RHEL), but some minimal implementations, including older BusyBox builds used in Alpine images, only accept numbers. If the crontab has to run in containers too, 1-5 is the safer form.
When both the day-of-month and the day-of-week field are restricted (neither is *), cron runs the job when either matches, not when both match.
0 9 1-7 * 1Most people read this as “09:00 on the first Monday of the month”. Cron reads it as “09:00 on days 1 to 7 of the month, and 09:00 on every Monday”. That is up to eleven runs a month instead of one.
Standard cron cannot express “first Monday” on its own. The usual workaround is to schedule on the days and check the weekday in the command:
0 9 1-7 * * [ "$(date +\%u)" = 1 ] && /opt/scripts/monthly.shNote the \%, which brings us to the second trap.
In a crontab line, an unescaped % ends the command. Everything after it is passed to the command as standard input, with further % turned into newlines. So this line:
0 0 * * * tar czf /backup/site-$(date +%F).tgz /var/wwwruns tar czf /backup/site-$(date + and feeds the rest to its input. The job fails, and because the output goes to mail, often nobody sees why. Escape every percent sign as \%, or move the command into a script and call the script from the crontab.
Most cron implementations accept these instead of the five fields:
| Shortcut | Same as | Note |
|---|---|---|
@yearly / @annually | 0 0 1 1 * | Midnight, January 1st |
@monthly | 0 0 1 * * | Midnight, first day of the month |
@weekly | 0 0 * * 0 | Midnight between Saturday and Sunday |
@daily / @midnight | 0 0 * * * | |
@hourly | 0 * * * * | |
@reboot | none | Once, when the cron daemon starts |
@reboot runs when cron starts, which is usually boot, but also after a restart of the cron service. It is not a replacement for a proper service unit, and in containers it runs every time the container starts.
Be careful with @daily on many servers at once: they all fire at exactly 00:00, which is how backups end up competing for the same disk or the same API. Picking a random minute and hour per server (17 3 * * *) spreads the load.
The system crontab and the files in /etc/cron.d/ have a sixth field before the command: the user that runs it.
# crontab -e (user crontab): no user field
30 2 * * * /opt/scripts/backup.sh
# /etc/crontab and /etc/cron.d/*: user field
30 2 * * * root /opt/scripts/backup.shCopying a line from one to the other without adding or removing the user field is a classic way to get a job that never runs: in /etc/cron.d/ the command /opt/scripts/backup.sh would be treated as a user name. Files in /etc/cron.d/ also have naming rules (no dots on Debian) and must end with a newline, covered in the first post of the series.
| Schedule | Expression |
|---|---|
| Every 5 minutes | */5 * * * * |
| Every hour at minute 0 | 0 * * * * |
| Every 2 hours | 0 */2 * * * |
| Every day at 02:30 | 30 2 * * * |
| Twice a day, 06:00 and 18:00 | 0 6,18 * * * |
| Weekdays at 09:00 | 0 9 * * 1-5 |
| Every 15 minutes during office hours on weekdays | */15 9-17 * * 1-5 |
| Saturday and Sunday at 10:00 | 0 10 * * 6,0 |
| First day of the month at 00:00 | 0 0 1 * * |
| Every quarter, first day at 06:00 | 0 6 1 1,4,7,10 * |
| Last day of the month | 0 23 28-31 * * [ "$(date -d tomorrow +\%d)" = 01 ] && cmd |
Paste any of them into the cron parser to see the next runs before you save the crontab. The last one uses GNU date; on BusyBox, check the day of month in the script instead.
* 2 * * * instead of 0 2 * * *. The first runs every minute from 02:00 to 02:59, sixty times.% in the command.*/n with n that does not divide 60 or 24, expecting an even interval.crontab -e or a missing one in /etc/cron.d/.A valid expression only says when cron should start the command. It does not tell you whether it actually started, finished, or failed at 02:30 while everyone was asleep. That is what a heartbeat monitor is for: the job pings when it starts and ends, and you get an alert when a ping is late or the exit code is not zero.
With HelloCron that is two curl calls around the existing command, or one wrapper that also reports the exit code:
30 2 * * * /usr/local/bin/hellocron run nightly-backup /opt/scripts/backup.shMonitor a cron job in 60 seconds shows the setup, and the cron jobs guide covers the details. The free plan includes 15 monitors.
* every, , list, - range, / step from the lowest value.% as \%, or call a script./etc/crontab and /etc/cron.d/ need a user field, crontab -e does not.Next in the series: cron environment variables, why your script works in the shell but not in cron, and how to give cron the environment it needs.
Sign up, get an API key, paste the curl into your cron. That's it.