A cron job has two parts: the cron expression and the crontab. The crontab is the file that pairs each schedule with a command. It has its own syntax and its own environment, plus several rules that can stop a job from running without any warning. This guide covers editing and listing crontabs, the difference between user and system crontabs, the environment variables that matter, and the escaping rules that most people trip over at least once.
Anatomy of a crontab line
A user crontab line is a cron expression followed by the command to run. Everything after the fifth field is the command, spaces included, and cron does not need quotes around it.
# ┌───────────── minute (0-59)
# │ ┌─────────── hour (0-23)
# │ │ ┌───────── day of month (1-31)
# │ │ │ ┌─────── month (1-12)
# │ │ │ │ ┌───── day of week (0-6, Sunday = 0)
# │ │ │ │ │
30 2 * * * /usr/local/bin/backup.sh --full
Blank lines are ignored and lines starting with # are comments. Comments must be on their own line: cron does not strip a trailing # from a command line, so 0 3 * * * /bin/job.sh # nightly passes # nightly to the shell rather than discarding it. That is harmless in this example, but it can cause problems when the comment contains a character the shell acts on.
Use the builder to compose the schedule and the explainer to verify a line you inherited; this guide covers everything to the right of the fifth field.
Editing and listing your crontab
Never edit the spool file directly. The crontab command validates syntax and signals the daemon to reload; hand-editing /var/spool/cron/ does neither.
| Command | What it does |
|---|---|
crontab -e | Open your crontab in an editor. On save, cron parses it and rejects the whole file if any line is malformed. |
crontab -l | Print your crontab to standard output. |
crontab -r | Delete your crontab. There is no confirmation and no undo. |
crontab -i -r | Delete with a confirmation prompt. It is worth aliasing crontab -r to this. |
crontab -u deploy -l | List another user's crontab. Requires root. |
crontab jobs.txt | Replace your entire crontab with the contents of a file. This lets you keep cron jobs in version control. |
Deleting a crontab by typo happens often enough that it turns up regularly in incident reports. Keep the authoritative copy in version control and deploy it with crontab jobs.txt, so a stray -r costs you a redeploy rather than a reconstruction from memory.
Choosing the editor
crontab -e uses $VISUAL, then $EDITOR, then a system default that is often vi. To use a different editor, set it explicitly:
EDITOR=nano crontab -e
User crontabs vs system crontabs
There are two formats, and they differ by one extra field. Mixing them up is a frequent reason for "my job never runs".
| Location | Format | Notes |
|---|---|---|
crontab -e | 5 fields + command | Per-user. Runs as the owning user. Stored under /var/spool/cron/. |
/etc/crontab | 5 fields + user + command | System-wide. The sixth field names the user to run as. |
/etc/cron.d/* | 5 fields + user + command | Drop-in files, same format as /etc/crontab. The usual place for package- and config-management-owned jobs. |
/etc/cron.daily/ | Executable scripts, no schedule | Also hourly, weekly, monthly. Run by run-parts at a time the distribution chooses. |
A system crontab line looks like this. Note the root before the command:
30 2 * * * root /usr/local/bin/backup.sh --full
Put that same line in a user crontab and cron tries to execute a program called root. Put a user crontab line in /etc/cron.d/ and cron treats the first word of your command as the username, fails to resolve it, and logs an error that you will only see if you check the logs.
With run-parts semantics, filenames containing a dot are skipped entirely. Cron silently ignores a drop-in named backup.cron or my-job.sh, so name it backup instead. The same rule applies to scripts in /etc/cron.daily/.
The environment: PATH, SHELL and MAILTO
The environment causes more "it works in my terminal but not in cron" problems than anything else. Cron does not read .bashrc, .bash_profile, .profile or any other login file, so none of your aliases, exported variables, version manager shims or PATH additions are present.
A typical cron environment looks roughly like this:
SHELL=/bin/sh
PATH=/usr/bin:/bin
HOME=/home/youruser
LOGNAME=youruser
Two consequences follow. First, /usr/local/bin is usually not on the path, so anything you installed yourself will not be found by name. Second, the shell is /bin/sh, not bash, so bashisms such as [[ ]], &> and arrays may fail depending on what sh points at.
You can set variables at the top of a crontab. Assignments apply to every line below them:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=ops@example.com
0 3 * * * /usr/local/bin/nightly.sh
| Variable | Effect |
|---|---|
SHELL | Which shell runs each command. Defaults to /bin/sh. |
PATH | Command lookup path. It is deliberately minimal by default. |
MAILTO | Address that receives any output a job produces. MAILTO="" disables mail entirely. |
CRON_TZ | Time zone for the schedules below it, in Vixie-derived crons. See the time zones guide. |
HOME | Working directory for the job. Defaults to the user's home. |
Variable assignment in a crontab is literal and does not follow shell syntax. PATH=$PATH:/opt/bin does not expand. Cron stores the string $PATH:/opt/bin verbatim. Write the full path out, or set it inside the script instead.
Keep the crontab line trivial and put all the logic in a script: 0 3 * * * /usr/local/bin/nightly.sh. Inside the script, use a shebang, set the variables you need, and use absolute paths. One-liners with pipes and redirects in a crontab tend to collect escaping bugs. A script can also be run by hand for testing, which a crontab line cannot.
Escaping: the percent sign rule
Inside a crontab, an unescaped % has a special meaning. Cron removes the first one and everything after it from the command and feeds that text to the command on standard input, turning each later % into a newline. The feature exists for supplying input to a program, but it catches out anyone who uses date in a command.
# Wrong: everything from %Y onward becomes stdin
0 1 * * * /bin/tar czf /backup/$(date +%Y%m%d).tgz /srv
# Right: escape each percent
0 1 * * * /bin/tar czf /backup/$(date +\%Y\%m\%d).tgz /srv
Because this rule applies only to crontabs and not to scripts, the same command that fails as a crontab line works unchanged inside a shell script. That is another reason to keep the crontab line trivial.
The missing final newline
Historically, a crontab file whose last line had no trailing newline would have that last job silently ignored. Modern crontab -e fixes this for you, but a crontab installed from a file (crontab jobs.txt) or written into /etc/cron.d/ by a deploy script can still hit it. If your last job is the only one not running, check for the newline first.
Capturing output and exit codes
By default, anything a cron job writes to stdout or stderr is mailed to the job's owner. On a machine with no mail transport configured, which describes most machines now, that output goes nowhere. A job can fail every night for months without anyone noticing.
Redirect output explicitly:
# Append both streams to a log
0 3 * * * /usr/local/bin/nightly.sh >> /var/log/nightly.log 2>&1
# Log errors only; discard normal output
0 3 * * * /usr/local/bin/nightly.sh > /dev/null
# Timestamp each line
0 3 * * * /usr/local/bin/nightly.sh 2>&1 | /usr/bin/ts >> /var/log/nightly.log
A common anti-pattern is > /dev/null 2>&1, which discards errors along with normal output, so a broken job stays broken without anyone knowing. If you want no output on success and a report on failure, have the script exit non-zero and write to stderr only when something goes wrong. Cron will then mail you (if mail works), or your monitoring will notice the non-zero exit. A dead-man's-switch check-in, described in the best practices guide, is a better option still.
Permissions and cron.allow
Whether a user may install a crontab at all is controlled by two files:
/etc/cron.allow: if it exists, only users listed in it may usecrontab./etc/cron.deny: consulted only whencron.allowdoes not exist. Users listed here are refused.- If neither file exists, behaviour depends on the distribution: some allow everyone, some allow only root.
A user who is refused sees You (name) are not allowed to use this program from crontab -e. These files govern installing crontabs, not running jobs. A job that is already installed keeps running even if the user is later added to cron.deny.
Beyond that, ordinary Unix permissions apply. The script must be executable (chmod +x), readable by the user cron runs it as, and every directory in its path must be traversable by that user. A job that runs as www-data cannot read a script in a home directory set to mode 700.
A checklist before you save
- Does the schedule mean what you think? Paste it into the Explainer and read the next five run times, not the syntax.
- Are both day-of-month and day-of-week restricted? If so, cron applies OR logic and the job runs more often than you expect.
- Is every path absolute, including the interpreter, the script, the data files and the log?
- Is every
%escaped as\%? - Is output redirected to a place you check regularly?
- Does the script run correctly under a minimal environment? Test with
env -i /bin/sh -c '/usr/local/bin/yourjob.sh'. - Is the file version-controlled and deployed with
crontab jobs.txt, rather than typed into an editor on a production box? - Can the job overlap with itself if it runs long? If so, wrap it in
flock.
If a job still does not fire after all of that, work through the troubleshooting guide, which starts at the daemon and the logs rather than the crontab.