Skip to content

Crontab Syntax, Examples, and Why Cron Jobs Fail Silently

A crontab line is five time fields followed by a command: minute, hour, day of month, month and day of week. 30 2 * * * /home/sam/bin/backup.sh runs a backup script at 2:30 every morning. The syntax is short, but it has a few traps, and cron will start a job that then fails without anyone finding out.

Most examples assume a common Linux cron, such as cronie or Debian's Vixie-derived cron. FreeBSD, OpenBSD and macOS use the same syntax with a few useful differences, covered in cron on FreeBSD, OpenBSD and macOS.

This guide is published by Watchgoose, a monitor for cron jobs and other scheduled work. To try an expression as you read, paste it into our cron expression cheatsheet and tester.

Table of contents

What crontab is

A crontab ("cron table") is a plain text list of scheduled commands. The cron daemon reads every crontab on the machine once a minute and starts any command whose schedule matches the current time.

Each user has their own crontab, and its jobs run with that user's permissions. There is also a system-wide crontab, usually /etc/crontab, plus drop-in files in /etc/cron.d/. Those use a slightly different format, which we cover below.

Cron's job ends the moment it starts your command. It doesn't check whether the backup was written, whether the report was sent, or whether the script exited with an error. Keep that in mind; it explains most of the trouble people have with cron.

The five schedule fields

A line in a user crontab 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 * * * /home/sam/bin/backup.sh

That example runs /home/sam/bin/backup.sh at 2:30 every morning, in the server's local time.

Scroll horizontally to see all columns.

FieldAllowed valuesExampleMeaning of the example
Minute0–5930At minute 30
Hour0–232At 2 a.m.
Day of month1–31*Any day of the month
Month1–12 or JAN–DEC*Every month
Day of week0–7 or SUN–SAT*Any day of the week

Always give the command as a full path. Cron usually starts jobs in your home directory, but with a much shorter PATH than your login shell, so backup.sh on its own often won't be found.

The system crontab has an extra field

Lines in /etc/crontab and /etc/cron.d/* have a sixth field, the user to run the command as, between the schedule and the command:

30 2 * * * root /usr/local/bin/backup.sh

Don't copy that format into your own crontab. There, root would be treated as the name of the command to run.

When day of month and day of week are both set

If both the day-of-month field and the day-of-week field are restricted (neither is *), the job runs when either one matches, not both.

0 9 1 * 5 /home/sam/bin/report.sh

You might read that as "9 a.m. on the 1st, if it's a Friday". Cron runs it at 9 a.m. on the 1st of every month and at 9 a.m. every Friday. If you really need "the first Friday of the month", schedule it for days 1–7 and check the weekday inside the command:

0 9 1-7 * * [ "$(date +\%u)" = 5 ] && /home/sam/bin/report.sh

The \% matters; see how cron treats the percent sign.

Special characters: *, lists, ranges, and steps

Four bits of punctuation cover nearly every schedule you'll write:

  • * means every allowed value. * in the hour field means every hour.
  • Lists use commas. 0,15,30,45 in the minute field means those four minutes.
  • Ranges use a hyphen. 1-5 in the day-of-week field means Monday to Friday.
  • Steps use a slash. */15 in the minute field means every 15 minutes, starting from 0. 0-30/10 means minutes 0, 10, 20, and 30.

You can combine them: 0 8-18/2 * * 1-5 runs on the hour every two hours from 8 a.m. to 6 p.m., Monday to Friday.

Steps count from the start of the field's range, not from when the job last ran. */7 in the minute field runs at minutes 0, 7, 14 … 56, then again at minute 0, only four minutes later. If you need an even interval that doesn't divide 60, a clock-based schedule like cron isn't the right tool.

Many cron implementations also accept shortcuts in place of the five fields, such as @reboot, @hourly, @daily, @weekly, @monthly, and @yearly. @daily is the same as 0 0 * * *. @reboot runs once when the cron daemon starts, which usually means at boot.

Common crontab examples

Scroll horizontally to see all columns.

ExpressionRuns
* * * * *Every minute
*/5 * * * *Every 5 minutes
*/15 * * * *At minutes 0, 15, 30, and 45 of every hour
0 * * * *At the start of every hour
0 */6 * * *At midnight, 6 a.m., noon, and 6 p.m.
30 2 * * *Every day at 2:30 a.m.
15 8 * * 1-5At 8:15 a.m., Monday to Friday
0 3 * * 0Every Sunday at 3 a.m. (7 or SUN work too)
0 1 1 * *At 1 a.m. on the first day of every month
0 0 1 */3 *At midnight on the first day of every third month
0 9 * * MONEvery Monday at 9 a.m.

Want to see the next run times for an expression before you commit to it? The cron expression tester translates any expression into plain English and lists upcoming runs.

Watch out for overlapping runs

*/15 means "start a new copy every 15 minutes", not "wait 15 minutes after the last one finishes". If a run sometimes takes longer than the interval, two copies will run at once. For anything that writes to the same files or database rows, guard it with a lock. On Linux, flock is the simplest way:

*/15 * * * * /usr/bin/flock -n /tmp/sync.lock /home/sam/bin/sync.sh

With -n, a new run gives up straight away if the previous one still holds the lock, instead of piling up behind it.

Editing and viewing a crontab

Use the crontab command rather than editing files by hand:

  • crontab -e opens your crontab in your editor and installs it when you save.
  • crontab -l prints your current crontab.
  • crontab -r deletes your crontab without asking. It sits right next to -e on the keyboard, so take care.
  • sudo crontab -u alice -l shows another user's crontab.

crontab -e also checks the syntax when you save, and refuses to install a file it can't parse. A schedule can still be valid and wrong, of course, so read it back in plain language. A comment on the line above each job helps the next person, including you in six months:

# Nightly database backup, 02:30 server time
30 2 * * * /home/sam/bin/backup.sh

Put comments on their own line. Text after the command is passed to the command, not ignored.

Who may use crontab at all is controlled by /etc/cron.allow and /etc/cron.deny. If neither file exists, the defaults depend on the distribution.

How cron runs your command

This is where "it works when I run it" turns into "it never runs in cron". Cron doesn't start an interactive login shell, so a lot of what you're used to isn't there.

A minimal PATH. Cron typically sets PATH to something like /usr/bin:/bin. Tools in /usr/local/bin, ~/bin, or a language version manager won't be found. Use full paths, or set PATH at the top of the crontab:

PATH=/usr/local/bin:/usr/bin:/bin

/bin/sh, not your shell. Commands run under /bin/sh unless the crontab sets SHELL. On Debian and Ubuntu, /bin/sh is dash, which doesn't understand Bash features like [[ ]], arrays, or source. Either set SHELL=/bin/bash in the crontab, or put the logic in a script that starts with #!/bin/bash.

No profile, no aliases. .bashrc, .profile, and your aliases aren't loaded. Environment variables your script needs, such as a database URL or an API key, must be set explicitly. Keep secrets in a file only the job's user can read, not in a world-readable crontab.

A different working directory. Jobs usually start in the user's home directory. A script that opens data.csv by a relative path will look for it there. Use absolute paths, or cd to the right directory first.

How cron treats the percent sign

In a crontab, an unescaped % ends the command. Everything after the first % is sent to the command as standard input, with further % signs turned into newlines. That breaks commands like this:

0 0 * * * tar czf /backups/site-$(date +%F).tar.gz /var/www

Escape each percent sign with a backslash, date +\%F, or move the command into a script, where % has no special meaning.

Time zones and daylight saving

Cron uses the server's local time zone. If the server is set to UTC and you're in Berlin, 0 9 * * * runs at 10:00 or 11:00 Berlin time depending on the season. Check the zone before you schedule anything that has to run at a particular local time:

timedatectl | grep "Time zone"

Some implementations, such as cronie on Fedora and RHEL, also support a CRON_TZ variable that sets the zone for the jobs that follow it. Support varies, so check man 5 crontab on your system before relying on it.

Daylight saving causes two classic problems. When clocks go forward, a job scheduled inside the skipped hour may not run that day, or may run late, depending on the implementation. When clocks go back, a job in the repeated hour can run twice. Jobs scheduled between about 1 a.m. and 3 a.m. are the usual victims. If a run must happen exactly once, schedule it outside that window or keep the server on UTC.

Where the output goes

Anything a job prints to standard output or standard error is emailed to the crontab's owner, if the machine has a working outbound mail setup. Without one, the output is discarded, along with the error message that would have told you what went wrong. MAILTO=you@example.com at the top of the crontab changes the recipient, and MAILTO="" turns mail off.

The dependable alternative is to write output to a log file:

30 2 * * * /home/sam/bin/backup.sh >> /home/sam/logs/backup.log 2>&1

>> appends instead of overwriting, and 2>&1 sends errors to the same file. Make sure the directory exists and the job's user can write to it, and rotate the log with logrotate so it doesn't slowly fill the disk.

Cron's own log only tells you that a job started. Where to find it depends on the system:

  • Debian and Ubuntu: journalctl -u cron, or grep CRON /var/log/syslog.
  • Fedora, RHEL, and Rocky Linux: journalctl -u crond, or /var/log/cron.

Cron on FreeBSD, OpenBSD and macOS

The BSDs and macOS all run descendants of Paul Vixie's cron, the same family as Debian's. Everything above about the five fields, *, lists, ranges, steps, the day-of-month and day-of-week rule, and the % sign applies unchanged. The differences are in where files live, what the environment looks like, and a few handy extras.

Scroll horizontally to see all columns.

FreeBSDOpenBSDmacOS
User crontabs/var/cron/tabs//var/cron/tabs//usr/lib/cron/tabs/
System crontabs/etc/crontab, /etc/cron.d/, /usr/local/etc/cron.d//etc/crontab/etc/crontab
Default PATHIncludes /usr/local/binMinimalMinimal
Cron log/var/log/cron/var/cron/logNone by default
Check the daemonservice cron statusrcctl check cronsudo launchctl list | grep cron
Built-in housekeepingperiodic(8)/etc/daily, /etc/weekly, /etc/monthlylaunchd

On all three, use crontab -e and crontab -l exactly as on Linux. Don't edit files in the spool directory by hand; cron may not notice the change, and OpenBSD ignores a user crontab whose permissions aren't 0600.

FreeBSD

FreeBSD's /bin/sh is an Almquist shell, not Bash, so the advice about Bash-only syntax applies here too. Bash, if installed from packages, lives at /usr/local/bin/bash, so set SHELL=/usr/local/bin/bash if you need it. One welcome difference: when nothing else sets it, FreeBSD's cron sets PATH to /sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin, so commands installed from packages are found without full paths.

FreeBSD's cron adds a few things Linux crons lack:

  • @every_minute and @every_second shortcuts, alongside the usual @daily and friends.
  • @ followed by a number of seconds runs a job that many seconds after the previous run finishes. @300 /usr/local/bin/sync.sh runs five minutes after each run ends, so two copies never overlap during normal operation.
  • Per-job options before the command. -n mails output only when the command exits with a non-zero status, which cuts the noise from chatty jobs. -q keeps the job out of the cron log.
# Mail output only when the backup fails
30 2 * * * -n /usr/local/bin/backup.sh
# Run 10 minutes after each sync finishes, never overlapping
@600 /usr/local/bin/sync.sh

Packages that need scheduled work drop files into /usr/local/etc/cron.d/, which uses the system format with a user field. Daily, weekly, and monthly maintenance is run by periodic(8), called from /etc/crontab and configured in /etc/periodic.conf. Put your own maintenance scripts under /usr/local/etc/periodic/daily/ and friends rather than adding more crontab lines.

FreeBSD's cron(8) also has a -s option for daylight-saving shifts. Jobs that run less often than hourly then run exactly once, instead of being skipped or repeated. Jobs that run every hour or more often are scheduled as usual, so they can still skip or repeat runs in the shifted hour. Check cron_flags in /etc/rc.conf and man 8 cron on your release to see how it's set.

OpenBSD

OpenBSD's cron has two features that solve problems discussed earlier without any extra tools:

  • -s before the command runs a single instance at a time. Further runs aren't started until the current one finishes, so you don't need flock to prevent overlaps.
  • ~ picks a random value, chosen when the crontab is loaded. ~ 3 * * * runs once a day at a random minute between 3:00 and 3:59, which spreads load when many machines share a schedule. 0~30 limits the choice to minutes 0 to 30, and ~/30 runs twice an hour at a random but consistent offset.
# Nightly backup at a random minute past 3, one copy at a time, mail only on failure
~ 3 * * * -sn /home/sam/bin/backup.sh

-n and -q work as on FreeBSD. Unlike FreeBSD, OpenBSD doesn't allow ranges or lists of day and month names, so write 1-5 instead of MON-FRI.

OpenBSD handles daylight saving itself, but only for jobs that run at a specific time or less often than hourly. For those, after a change of less than three hours, jobs in a skipped interval run immediately and jobs in a repeated interval aren't run twice. Jobs that run every hour or more often are scheduled normally. Cron's own log is /var/cron/log, and the root crontab runs the system's /etc/daily, /etc/weekly, and /etc/monthly scripts.

macOS

macOS still ships cron, and crontab -e works, but Apple treats it as legacy. The manual notes that its functionality has been absorbed into launchd, and launchd starts cron only when a crontab exists. For new work on a Mac, a launchd agent is the supported route. For a quick personal job, cron is fine.

Two things trip people up on a Mac:

  • Privacy protection. Recent macOS versions block cron from protected folders such as Desktop, Documents, Downloads, and external drives. A job that touches them fails with "Operation not permitted" until you add /usr/sbin/cron under System Settings, Privacy & Security, Full Disk Access.
  • Sleep. Cron doesn't run jobs while the Mac is asleep, and it doesn't catch up afterwards. A laptop that sleeps overnight will simply miss a 2 a.m. job. launchd jobs scheduled with StartCalendarInterval do run once after the Mac wakes.

Cron on macOS doesn't keep a log file of its own, so redirect each job's output to a file as shown in where the output goes.

Why cron jobs fail and how to debug them

When a job doesn't do what you expected, work through these in order. Each step rules out one cause before you change anything.

  1. Is cron running? systemctl status cron (or crond on Fedora and RHEL). On the BSDs and macOS, use the commands in the table above.
  2. Is the entry where you think it is? crontab -l as the right user. A job added with sudo crontab -e lives in root's crontab, not yours.
  3. Did cron start it? Check cron's log at the time the job was due.
  4. Does it run in a cron-like environment? As the same user, start from your home directory (where cron usually starts jobs) with a stripped-down environment: cd ~ && env -i HOME="$HOME" PATH=/usr/bin:/bin /bin/sh -c '/home/sam/bin/backup.sh'. If it fails here but works in your normal shell, the cause is PATH, a missing variable, the shell, or a relative path.
  5. Can the user reach everything? The script must be executable, and every file, directory, socket, and service it touches must be accessible to the job's user.
  6. What did it print? Capture output to a log file as shown above, then read the error.
  7. What exit code did it return? A script can print an error and still exit with 0 if it doesn't check for failures, and then && curl reports a success that didn't happen. set -e helps but isn't a guarantee: it ignores failures in all but the last command of a pipeline (false | cat exits 0), inside if conditions, and in commands joined with && or ||. In Bash scripts, also use set -o pipefail, and check the commands that matter explicitly, for example pg_dump mydb > db.sql || exit 1.

Missed runs while the server was off

Cron doesn't catch up. If the server is off, rebooting, or suspended when a job is due, that run is simply skipped. For laptops and machines that aren't always on, use anacron, which runs daily, weekly, and monthly jobs once the machine is back. On systemd-based systems, a timer with OnCalendar= and Persistent=true runs a missed calendar event as soon as the timer starts again. Persistent= has no effect on other timer types.

Knowing when a job stops running

Everything above helps you work out why a job failed, but only after someone notices. Cron can email a job's output, but it never warns you about a run that didn't happen or a job that exits quietly with nothing to say. A backup can stop working on a Tuesday and nobody finds out until the restore that needs it.

The fix is to have the job report back when it finishes, and to have something waiting for that report. This is what Watchgoose does. Each job gets a check with its own ping URL. You add one request to the end of the cron line, after the real work:

30 2 * * * /home/sam/bin/backup.sh && curl -fsS -m 10 --retry 5 -o /dev/null https://watchgoose.com/ping/your-uuid-here

The && matters. The ping is only sent if the backup command exits successfully, so a failed backup looks exactly like a missing one.

In Watchgoose, you give the check the same five-field cron expression and server time zone as the job, plus a grace time for runs that finish a little late. Watchgoose accepts standard five-field expressions only, not @ shortcuts such as @daily, FreeBSD's @600-style intervals, or OpenBSD's ~ random values. For those, write the equivalent five fields (0 0 * * * for @daily), or use a simple period schedule (for example, every 10 minutes) with a grace time that covers the variation. After the first successful ping, Watchgoose expects the next one on schedule. If it doesn't arrive within the grace time, Watchgoose alerts you by email, Slack, PagerDuty, SMS, webhooks, or any of its other alert channels. The cron monitoring guide walks through the setup step by step.

A few extras are worth knowing about:

  • Report failures straight away. Instead of waiting for a ping that never comes, send the exit code: curl -fsS -m 10 --retry 5 -o /dev/null https://watchgoose.com/ping/your-uuid-here/$?. A non-zero code marks the check as failed immediately. See signaling failures.
  • Catch jobs that hang. Ping https://watchgoose.com/ping/your-uuid-here/start when the job begins, and Watchgoose records how long each run takes, and alerts you if a job starts but doesn't finish within its grace time. See measuring script run time.
  • Nothing to install. There's no agent or SDK; a curl line is enough. Jobs that can only send email can report by email instead.

The free allowance covers 10 checks with no credit card, which is enough for the handful of jobs you can't afford to lose: backups, billing exports, certificate renewals, and the reports people actually read.

A ping proves the job reported success. It doesn't prove a backup can be restored, so test your restores separately.

Frequently asked questions

What does crontab do?

Crontab stores a list of commands and the times they should run. The cron daemon checks those entries every minute and starts any command whose schedule matches. Cron only starts the command; it does not check whether the command succeeded.

How do I see my crontab?

Run crontab -l as the user whose jobs you want to see, and crontab -e to edit them. Root's jobs are separate, so use sudo crontab -l for those. System-wide jobs live in /etc/crontab and /etc/cron.d/.

What does an asterisk mean in crontab?

An asterisk means every allowed value for that field. In the minute field it means every minute, and in the day-of-week field it means every day. Add a step, such as */10, to run every tenth value instead.

Is 0 or 7 Sunday in crontab?

Both. In the day-of-week field, 0 and 7 both mean Sunday, and you can also write SUN. Monday is 1 and Saturday is 6.

Why does my cron job work manually but not in cron?

Cron runs commands with a minimal environment: a short PATH, /bin/sh instead of your usual shell, no profile or aliases, and your home directory as the working directory. Use full paths, set any variables the job needs, and escape percent signs. Then capture the output to a log file to see the actual error.

Is BSD cron syntax the same as Linux?

Yes. FreeBSD, OpenBSD, macOS, and most Linux distributions use the same five fields and the same special characters. The differences are extras: FreeBSD adds @every_minute and @ followed by a number of seconds, OpenBSD adds ~ for random values and -s for single-instance jobs, and file locations and default PATH differ.

Does cron run jobs it missed while the server was off?

No. Cron skips any run that was due while the machine was off or the daemon was stopped. Use anacron, or a systemd timer with OnCalendar= and Persistent=true, if missed runs must catch up, and monitor the job so you know when a run never happened.

Conclusion

Most crontab problems come from the environment, not the schedule. Cron also won't warn you when a job stops running, so give the jobs you can't afford to lose a check that alerts you when their success ping doesn't arrive. Try the schedule first in the cron expression tester.

References

Monitor your first cron job