All posts

The crontab file: PATH, MAILTO and the `%` that becomes a newline

7 min read

A crontab is not a shell script. It runs with a different PATH, mails its output to somewhere you are not looking, and turns an unescaped % into a newline.

Open /etc/crontab on a Debian box and two of its lines disagree about which day is Sunday.

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 )

The weekly line asks for day 7. The file beside it, /etc/cron.d/e2scrub_all, uses 0 for the same day. Both mean Sunday, because the day-of-week field runs 0 to 7 and Sunday sits at both ends of the range.

That one is easy. The five fields are the part you already know how to read, and the rest of the file is where the surprises are: the environment the daemon hands your command, where the output goes, and one character that gets rewritten before your shell ever sees it.

Where the file lives

Three places, and they do not use the same format.

Location Fields Who runs it
crontab -e 5 You, as the crontab's owner
/etc/crontab 5 + user The named user
/etc/cron.d/* 5 + user The named user

The user column is the entire difference. A personal crontab has nowhere to put a username because it belongs to exactly one account and the daemon already knows which one. Copy a line out of your own crontab into /etc/cron.d without adding that column and the daemon has nothing to run it as.

Your own crontab is not a file you are meant to open. It sits in /var/spool/cron/crontabs/<user> on Debian and /var/spool/cron/<user> on RHEL, and that directory carries mode drwx-wx--T: you can write into it through crontab -e, but you cannot list what is in it. That is why crontab -l exists, and why crontab -u alice -l is how you read a colleague's schedule if you have the rights to.

The four directories under /etc (cron.hourly, cron.daily, cron.weekly, cron.monthly) are not crontabs at all. They hold executables, and something else runs them: on Debian, the lines in /etc/crontab above, calling run-parts. So the filename rules there are run-parts's rules, not cron's.

$ mkdir /tmp/t && cd /tmp/t
$ printf '#!/bin/sh\necho ran\n' > backup.sh && chmod +x backup.sh
$ run-parts --list /tmp/t
$ mv backup.sh backup && run-parts --list /tmp/t
/tmp/t/backup

The first run-parts --list prints nothing and the second prints the path. The default filename check accepts letters, digits, underscores and hyphens only, so backup.sh and daily.bak are skipped with no warning. Rename the file, or wrap the script, or put the job in a crontab.

The expression syntax is the same in all three locations. When what you need is the symbol reference, or how Quartz and AWS EventBridge differ from POSIX, that is on the cron cheat sheet.

A crontab is not a shell script

The daemon does not start your shell and paste the line into it. It builds a small environment first, and none of the defaults are the ones your terminal has.

PATH is the one that bites. The Debian cron binary has /usr/bin:/bin compiled in as its fallback, so a crontab that never sets PATH runs with a two-entry search path. Anything in /usr/local/bin, anything under a language version manager, anything in ~/.local/bin is not found. You get mail saying command not found for a binary that works perfectly when you type it by hand.

Debian's own /etc/crontab carries a comment worth reading:

# You can also override PATH, but by default, newer versions inherit it from the environment

Whether an unset PATH falls back to the compiled-in /usr/bin:/bin or to whatever the daemon itself was started with depends on the distribution and the cron version. Neither is guaranteed, and neither is your login shell's path. Set PATH explicitly at the top of the crontab, or use absolute paths in the command, or both.

SHELL is the second default that matters, and it is /bin/sh. On Debian and Ubuntu that is dash, not bash:

$ readlink -f /bin/sh
/usr/bin/dash
$ /bin/sh -c '[[ 1 == 1 ]]'
/bin/sh: 1: [[: not found

No [[ ]], no arrays, no <<<, no source. Setting SHELL=/bin/bash on its own line at the top is a better fix than rewriting the job to be strictly POSIX, unless the job is going to run on a machine where bash is not installed.

HOME and LOGNAME are filled in from the passwd entry of the crontab's owner. HOME and SHELL can be overridden in the crontab; LOGNAME cannot. Environment lines go above the first job, since a setting only applies to the lines below it. The form is NAME=value, with spaces around the = optional and a value that may be quoted in matching single or double quotes to keep leading or trailing blanks.

Output goes to mail, and mail goes nowhere

Everything your job writes to stdout or stderr is collected and mailed to the crontab's owner. Not to your terminal, not to a file, not to the journal. A job that prints a progress line every night is a job that sends you mail every night, and a job whose author never installed an MTA is a job whose output is thrown away:

No MTA installed, discarding output

That single line in syslog is the whole story. Cron does not treat a missing mailer as an error, so a job that fails every night and a job that succeeds every night look identical from the outside. If you have ever concluded that a cron job "ran but did nothing", check whether it ran and talked into a void.

Three settings control where it goes. MAILTO=you@example.com sends output to that address. MAILTO="" turns mail off. Leaving it unset mails the crontab's owner, which on a server is often root and on a container is often nobody at all.

The usual fix is to stop relying on mail:

30 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

Keep the 2>&1. Without it, stderr keeps going to mail while stdout goes to the file, so you end up with a log containing only the happy path and an error message in an inbox nobody reads.

The % that becomes a newline

The rule is spelled out in crontab(5) and still catches people:

A "%" character in the command, unless escaped with a backslash (), will be changed into newline characters, and all data after the first % will be sent to the command as standard input.

It exists so that a crontab line can feed standard input without a here-document, which was useful when cron was written. It also breaks the most ordinary shell idiom there is:

0 3 * * * /usr/bin/date +%Y-%m-%d >> /var/log/stamp.log

Cron rewrites that command to date + and hands Y-%m-%d to it as stdin. date + is valid, so it succeeds: it prints a single newline. The log file gets a blank line every night and nothing anywhere reports a problem. Escape the percents (+\%Y-\%m-\%d) or move the command into a script, which is what most people end up doing after the second time.

Comments only count on their own line

# starts a comment only when it is the first non-blank character on the line. A trailing comment is not a comment, it is part of the command:

0 0 * * 0 /usr/local/bin/rotate.sh   # weekly

That line runs /usr/local/bin/rotate.sh with # and weekly as arguments. A script that ignores its arguments does the right thing by accident, sometimes for months, and then someone edits it to validate $1. The same restriction applies to environment lines, so PATH=/usr/bin:/bin # standard sets PATH to the string /usr/bin:/bin # standard.

Before you ship a line, read the fields back one at a time rather than trusting your memory of them. The cron parser lays the five fields out separately and reads the expression back in plain English, which is often enough to catch a field you took for a wildcard.

The five fields say when. Everything above the job lines is the daemon telling you what kind of environment you are about to get, and the environment is where the failures hide.

OCMA Tools

Free developer tools. Most features run client-side, your data stays in your browser. Optional accounts unlock extra features.

Most tools run client-side

© 2026 OCMA Tools — Free developer tools

built for developers, by developers