⏰ cron Guide

Schedule syntax, the minimal environment that breaks scripts, the percent-sign trap, overlapping runs, logging and when to use a systemd timer

Video: cron (sysadmin video)
Prefer to watch? This video runs the guide's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube. It is part of the Services, Storage and Security playlist.
cron: scheduling jobs with crontab
crontab -e

cron runs commands on a schedule: every five minutes, at 02:30 every night, on the first of the month, at boot. Each user has a crontab (a table of jobs), and the system has /etc/crontab plus the folders /etc/cron.d, cron.hourly, cron.daily, cron.weekly and cron.monthly. It is simple, it has been there for decades, and it fails in a few predictable ways: a minimal environment, a percent sign, two copies of a job running on top of each other, and output nobody reads.
Every example below ran for real: entries were installed in a user crontab on Ubuntu 24.04, allowed to fire, and the results read back (the crontab was empty before and was removed afterwards). The user is shown as demo and the host as server1. Commands that need root are shown without output.

The crontab and the five time fields

A crontab line is five time fields and then the command:
  minute  hour  day-of-month  month  day-of-week   command
   0-59   0-23     1-31       1-12     0-7 (0 and 7 are Sunday)
  30 2 * * *        /usr/local/bin/backup.sh         every day at 02:30
  */5 * * * *       /usr/local/bin/check.sh          every 5 minutes
  0 9 * * 1-5       /usr/local/bin/report.sh         09:00, Monday to Friday
  0 0 1 * *         /usr/local/bin/monthly.sh        midnight on the 1st of each month
  @reboot           /usr/local/bin/start-tunnel.sh   once, when the cron daemon starts
A star means "every". The shortcuts @hourly, @daily, @weekly, @monthly, @yearly and @reboot replace the five fields. Times are in the system's local time zone (check with timedatectl), so a job at 02:30 can be skipped or run twice when daylight saving changes: schedule important jobs away from 01:00 to 03:00 or run the server in UTC.
When both day-of-month and day-of-week are restricted, cron runs the job when EITHER matches: 0 0 13 * 5 runs on every 13th and on every Friday, not just on Friday the 13th.

Install, list and edit

  crontab -e          edit your crontab in $EDITOR (cron checks the syntax when you save)
  crontab -l          list it
  crontab -r          delete the whole crontab, with no confirmation
  crontab -ir         the same, but ask first (use this one)
  crontab FILE        install FILE as your crontab, replacing the old one
  sudo crontab -u USER -l        see another user's crontab
  sudo crontab -e                edit root's
A mistake is caught when the file is saved; the old crontab stays in place:
$ printf '61 * * * * /bin/true\n' > bad.cron
$ crontab bad.cron
"bad.cron":0: bad minute
errors in crontab file, can't install.
$ printf '* * * *  /bin/true\n' > bad2.cron
$ crontab bad2.cron
"bad2.cron":0: bad day-of-week
errors in crontab file, can't install.
The second one has only four time fields, so the command was read as a day-of-week: count your fields. Always keep a copy: crontab -l > ~/crontab.backup, and edit with crontab ~/crontab.backup if you want the file under version control.

Schedule syntax: lists, ranges and steps

  1,15,30 * * * *      at minutes 1, 15 and 30 of every hour (a list)
  0 8-17 * * *         at the top of each hour from 08:00 to 17:00 (a range)
  */10 * * * *         every 10 minutes: minutes 0, 10, 20, 30, 40, 50 (a step on the whole range)
  5-55/10 * * * *      at 5, 15, 25, 35, 45, 55 (a step within a range)
  0 */6 * * *          every 6 hours: 00, 06, 12, 18
  0 0 * * 0            Sunday at midnight (0 or 7)
  0 3 * * mon          names work for months and weekdays, but not inside lists and ranges: use numbers there
There is no way to say "every 90 minutes" or "every second Tuesday": run the job more often and test the date in the script, or use a systemd timer. Steps restart at each hour: */25 means minutes 0, 25 and 50, then 0 again, not every 25 minutes.

The environment cron gives your job

This is where most "works in my terminal, fails in cron" cases come from. cron does not run your shell startup files. It gives each job a tiny environment. Here is what a job really sees:
$ cat env.out        # written by a job whose command was: env | sort > env.out
HOME=/home/demo
LANG=en_US.UTF-8
LOGNAME=demo
MAILTO=
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
PWD=/home/demo
SHELL=/bin/sh
(MAILTO is empty only because the lab crontab set it.) Consequences:
  SHELL is /bin/sh, not bash: bash-only syntax ([[ ]], arrays, <( ) ) fails. Put SHELL=/bin/bash at the top of the crontab, or start the script with #!/bin/bash and run it by path.
  PATH is short: a tool outside it is not found, even though your terminal finds it:
$ cat nopath.out
/bin/sh: 1: mytool-that-is-not-in-path: not found
  Use full paths (/usr/bin/python3, not python3; which COMMAND tells you), or set PATH= at the top of the crontab.
  The working directory is your home folder: use absolute paths in scripts, or cd to the right folder first.
  No terminal: nothing can prompt for input, no colors, and variables like DISPLAY, SSH_AUTH_SOCK and anything from ~/.bashrc are missing.
You can set variables on their own lines at the top of the crontab; they apply to every job below:
  SHELL=/bin/bash
  PATH=/usr/local/bin:/usr/bin:/bin
  MAILTO=admin@example.com

The percent sign trap

In a crontab, an unescaped % means "newline", and everything after the first % is fed to the command as standard input. So a date format breaks the job without any error. These two lines are identical except for the backslash:
  * * * * * date +%Y > /home/demo/percent-bad.out 2>&1
  * * * * * date +\%Y > /home/demo/percent-good.out 2>&1
$ cat percent-good.out
2026
$ cat percent-bad.out
cat: percent-bad.out: No such file or directory
The bad job never even created its output file, because cron cut the line at the % and ran only "date +". The log shows what it really ran:
Oct 02 06:49:01 server1 CRON[9459]: (demo) CMD (date +)
Escape every % with a backslash, or move the command into a script and call the script, which is cleaner anyway:
  0 2 * * * /usr/local/bin/backup.sh                      # the script uses date +%F freely
  0 2 * * * tar czf /backup/site-$(date +\%F).tgz /var/www

Output, mail and logging

Whatever a job prints to stdout or stderr is mailed to the owner (or to MAILTO) if a mail system is installed, and silently thrown away if not. That is how failing jobs stay unnoticed for months. Decide on purpose:
  MAILTO=""                          no mail at all
  MAILTO=admin@example.com           mail every job's output to this address
  */5 * * * * /usr/local/bin/check.sh >> /var/log/check.log 2>&1       append stdout and stderr to a log file
  */5 * * * * /usr/local/bin/check.sh > /dev/null                      drop normal output, keep error mail (stderr)
>> appends, > overwrites on every run, and 2>&1 sends error messages to the same place as normal output. A job that says nothing when it works and prints a clear message when it fails is the best fit for MAILTO. Another way: have the script log through the journal, with logger -t myjob "backup finished", and read it with journalctl -t myjob (see the journalctl page).
A basic job and its output, as it really ran:
  * * * * * echo "ran at $(date +\%H:\%M:\%S)" >> /home/demo/basic.out 2>&1
$ cat basic.out
ran at 06:49:01

Where did it run? Reading the log

cron logs every job start, and PAM logs the session around it. On Ubuntu and Debian the daemon is the "cron" service and its messages go to the journal and to /var/log/syslog; on RHEL, Fedora and Alpine the service is called crond and the log is /var/log/cron.
$ systemctl is-active cron
active
$ journalctl -t CRON --since "3 minutes ago" --no-pager | grep demo | cut -c1-110
Oct 02 06:49:01 server1 CRON[9458]: (demo) CMD (echo "ran at $(date +%H:%M:%S)" >> /home/demo/basic.out 2>&1)
Oct 02 06:49:01 server1 CRON[9459]: (demo) CMD (date +)
Oct 02 06:49:01 server1 CRON[9449]: pam_unix(cron:session): session closed for user demo
Each CMD line means cron started the job. It does not say that the command succeeded. If a job's CMD line is in the log but nothing happened, the problem is in the command or its environment: run it the way cron does (see the common problems list). If there is no CMD line at all, cron never started it: check the schedule, the file name and permissions, and that the cron service is running (sudo systemctl status cron).

System crontabs: /etc/crontab, /etc/cron.d and the cron.* folders

System jobs live in files that have an extra field, the USER to run as, between the time and the command:
$ grep -v '^#' /etc/crontab
SHELL=/bin/sh

17 *	* * *	root	cd / && run-parts --report /etc/cron.hourly
25 6	* * *	root	test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }
47 6	* * 7	root	test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.weekly; }
52 6	1 * *	root	test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.monthly; }
Do not edit /etc/crontab for your own jobs. Instead:
  /etc/cron.d/NAME             a file in the same format as /etc/crontab (with the user field), one file per application; packages drop their schedules here. Ubuntu's own: anacron, e2scrub_all, sysstat
  /etc/cron.hourly, cron.daily, cron.weekly, cron.monthly       put an executable script there and it runs on that schedule, with no crontab syntax at all (the time comes from /etc/crontab, 06:25 for daily, but only when anacron is not installed; where it is, as on the lab machine, anacron runs them, see below)
Rules that make a file silently ignored on Debian and Ubuntu: in cron.d and cron.daily the file name may contain only letters, digits, underscores and hyphens (a dot, as in backup.sh, makes run-parts skip it), the script must be executable (chmod +x), and a file in cron.d must be owned by root and not writable by others. Test what would run with run-parts --test /etc/cron.daily.
anacron runs the daily, weekly and monthly jobs on machines that are not on all the time (laptops): a job missed because the machine was off runs at the next boot or login. A normal cron job never runs late; if the machine was off at 02:30, that run is simply gone.

Overlapping runs: flock

If a job takes longer than its interval, cron starts the next copy anyway, and two backups or two imports fight over the same files. flock takes a lock file and refuses to start a second copy:
  * * * * * /usr/bin/flock -n /var/lock/myjob.lock /usr/local/bin/myjob.sh
-n means "if the lock is taken, give up at once"; without it the second copy waits and the queue grows. The lock is released automatically when the job ends, even if it crashes. Run in the lab, a flock-wrapped job starts and finishes normally:
$ cat flock.out
locked job start
For a job that must not be late, use -w SECONDS to wait a limited time instead of -n. A related guard is timeout: /usr/bin/timeout 300 /usr/local/bin/myjob.sh stops it after 5 minutes.

A safe script template

Most cron problems disappear when the job is a script that sets up its own world. Call it with its full path from the crontab:
  #!/bin/bash
  set -euo pipefail
  PATH=/usr/local/bin:/usr/bin:/bin
  cd /srv/myapp || exit 1
  exec 9>/var/lock/myapp-nightly.lock
  flock -n 9 || { logger -t myapp-nightly "previous run still active, skipping"; exit 0; }
  logger -t myapp-nightly "started"
  /usr/bin/rsync -a --delete /srv/myapp/data/ /backup/myapp/ || { logger -t myapp-nightly -p user.err "rsync failed"; exit 1; }
  logger -t myapp-nightly "finished"
and in the crontab:
  30 2 * * * /usr/local/bin/myapp-nightly.sh
The script says what it did in the journal, quits on the first error, cannot overlap itself, and does not depend on a login environment. Test it the way cron runs it, with an empty environment and no terminal:
$ env -i HOME=$HOME /bin/sh -c '/usr/local/bin/myapp-nightly.sh' < /dev/null

Who may use cron

/etc/cron.allow lists the users who may have a crontab; if it exists, nobody else can. /etc/cron.deny lists users who may not. If neither file exists, which is the default on Ubuntu (both are missing on the lab machine), every user is allowed. A user who is refused gets "You (NAME) are not allowed to use this program (crontab)" and a log entry. Keep crontabs of other users out of sight: the files are in /var/spool/cron/crontabs, not readable by other users.

cron or a systemd timer?

A systemd timer (see the systemd Units and Timers page) can do what cron cannot:
  logs go to the journal automatically, and systemctl status shows the last result and the next run
  Persistent=true runs a missed job after the machine was off (like anacron)
  OnUnitActiveSec= gives true intervals ("90 minutes after the last run"), RandomizedDelaySec= spreads load, and resource limits and sandboxing apply
  dependencies: wait for the network or a mount before running
cron stays the right tool when the job is one line, you want it in a minute, and the machine is not a modern systemd server (BSD, containers, old systems). For a job you will still depend on in a year, a timer or a script with proper logging is easier to live with.

Common problems

The job never runs                     no CMD line in the log: wrong field count (the file would have been refused), the cron service is stopped (sudo systemctl status cron), the file in cron.d has a dot in its name or wrong owner, or the user is blocked in cron.deny
The job runs but does nothing          read its output: add >> /tmp/job.log 2>&1 to the line. Usual causes: a command missing from PATH, a relative path, a bash-only feature under sh, a variable that exists only in your login shell
It works in my terminal, fails in cron  reproduce cron's world: env -i HOME=$HOME PATH=/usr/bin:/bin /bin/sh -c 'COMMAND' < /dev/null
Date or percent signs break the line   escape % as \%, or move the command into a script
"bad minute", "bad day-of-week", "bad command" crontab -e refused the line: count the five fields (a missing space or a missing field shifts everything), and check the ranges
No such file or directory for a script the file has Windows line endings (CRLF): dos2unix FILE, or the first line #!/bin/bash^M cannot be found
Permission denied                      the script is not executable (chmod +x), or the crontab user cannot read the folder
It runs at the wrong time              time zone: timedatectl shows what the machine uses; cron does not read TZ from your shell. Also remember the daylight-saving hour
It ran twice, or ten copies are running the job takes longer than its interval and nothing stops the overlap: wrap it in flock -n
Mail piles up in /var/mail/USER         every job output is mailed: set MAILTO="" or redirect the output to a log file
crontab -r deleted everything          there is no undo: restore from the backup (crontab -l > file beforehand), or from /var/spool/cron/crontabs if a backup of the system exists
A job in cron.daily is skipped         the file name contains a dot (backup.sh), it is not executable, or it ends with a non-zero status: sudo run-parts --test /etc/cron.daily lists what would run
Different behavior in a container       most images do not run cron at all: start it in the foreground (cron -f), or run the schedule from the host, or use a scheduler outside the container

Tips

Put the command in a script, and call the script by its full path. Keep the crontab to scheduling only.
Set SHELL, PATH and MAILTO at the top of every crontab so nobody has to guess what the environment is.
Escape every percent sign, or avoid the problem with a script.
Decide where output goes: a log file, the journal via logger, or mail. Never leave it to chance.
Use flock for anything that can take longer than its interval, and timeout for anything that can hang.
Back up the crontab (crontab -l > crontab.backup), and prefer crontab -ir to crontab -r.
Add a comment above every entry saying what it does and who asked for it. Future you will not remember.
Test a new job with * * * * * first and watch the log: journalctl -t CRON -f. Then change it to the real schedule.