Cron syntax: everything after the fifth field

30/07/2026 · Laurent Scheduled tasks
Cron syntax: everything after the fifth field

Here is a line from a client crontab, copied exactly:

0 2 * * * cd ~/public_html && php artisan backup:run --filename=backup-$(date +%Y%m%d).zip

Every guide to cron syntax explains the first eleven characters and stops. Those eleven characters are correct. Everything after them is broken in three separate ways, and the job has been failing silently since the day it was written.

The percent sign is not a percent sign

Start with the one that catches everybody, because it is the only place where cron invents a meaning for a character that already had one.

In the command portion of a crontab line, an unescaped % is a newline. The first one also splits the line: everything before it is the command, everything after it is fed to that command on standard input. This is documented behaviour going back to the original implementation, it exists so you can pipe a short message into mail, and roughly nobody uses it on purpose.

So date +%Y%m%d inside a crontab does not produce a date. It produces a command that ends at date +, followed by two blank lines of input. The job runs, the date is empty, the filename is wrong, and the exit code may well be zero.

0 2 * * * ... --filename=backup-$(date +\%Y\%m\%d).zip

Backslash before each one. If the command has more than two of them, the honest move is to stop escaping and put the whole thing in a script file, where the percent sign means what it means everywhere else on the machine.

The tilde does not expand, and neither does much else

cd ~/public_html works when you type it. Under cron it depends on which shell cron hands the line to, and the default is /bin/sh, which on Debian and Ubuntu is dash. Dash expands a leading tilde in some positions and not in others, and relying on which is a decision you should not have to make at two in the morning.

The larger version of the same problem is the environment. A cron job does not source .bashrc, .bash_profile, .profile, or anything your hosting panel adds to an interactive session. It gets a nearly empty environment with HOME, LOGNAME, SHELL and a PATH that is typically /usr/bin:/bin and nothing else.

That single line explains most of the "it works when I run it manually" tickets in this trade. Your interactive shell has a PATH that includes the PHP version your host wants you to use, the composer global bin directory, and whatever a version manager injected. Cron has none of it. The job does not fail with a permissions error or a syntax error, it fails with php: command not found, sent to an email address that does not exist.

Find the real path before you write the line, not after:

command -v php        # what your shell resolves
* * * * * command -v php > /tmp/cron-php.txt 2>&1   # what cron resolves

Let that run for a minute, read the file, delete the line. On shared hosting the two answers are almost never the same, and the second one is the one that matters.

The sixth field that exists in half the files

A user crontab, the kind you edit with crontab -e, has five time fields and then the command. A system crontab, meaning /etc/crontab and every file in /etc/cron.d/, has five time fields, then a user name, then the command.

# /etc/cron.d/backup
0 2 * * * www-data /usr/local/bin/backup.sh

Copy that line into a user crontab and cron will try to execute a program called www-data. Copy a user crontab line into /etc/cron.d/ and cron will read your command name as a user name, fail to find the account, and refuse the whole file, which on some implementations means it stops parsing the other entries in it too.

This matters most in the direction people rarely think about. You are handed a working line by a hosting provider's documentation, or by a plugin's install instructions, and it is written for the other kind of file. Nothing in the line tells you which. The only reliable tell is whether the sixth token looks like an account.

Reading a line out loud, in the order that finds faults

The technique is not "say the fields". It is to read the line as two separate claims and check each against something real.

The schedule, with the word "of". Every fifteenth minute of the hour. Every fifth hour of the day. If the sentence you produce is not the sentence you meant, the expression is wrong, and the step operator is usually why: the three traps in a cron expression covers that in full.

The command, as if the machine had just booted. Read it assuming no PATH, no profile, no current directory, no aliases, no interactive shell. Every binary named by its full path. Every file named by its full path. Every relative path either removed or preceded by an explicit cd to an absolute location. Every % escaped.

Then run it that way, before you commit it:

env -i /bin/sh -c '/usr/bin/php /home/client/public_html/artisan backup:run'

env -i clears the environment, which is the closest thing to a cron rehearsal you can get from your own terminal. If it survives that, it will survive cron.

The three lines at the top of the file

A crontab can set environment variables, and the three worth setting are the three that turn a silent job into a debuggable one.

SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""

SHELL so you know which one you are writing for. PATH so the job does not depend on a default that varies between distributions. And MAILTO set deliberately, either to a real address you read or to the empty string.

The empty string deserves a word, because it looks like the careless option and is often the correct one. Cron mails any output a job produces to the crontab owner. On a machine with no working mail transport, which is most containers and many shared accounts, that mail is generated, fails to send, and lands in a spool directory that grows for years. Setting MAILTO="" and redirecting output to a file you can actually read is the honest version of what most people get by accident.

0 2 * * * /usr/local/bin/backup.sh >> /home/client/logs/backup.log 2>&1

The 2>&1 comes after the redirect, not before. Reversed, it points standard error at wherever standard output was pointing at the time, which is still the terminal, which under cron is nowhere.

What none of this fixes

A line that is syntactically perfect and environmentally correct still tells you nothing on the day it stops running. Deleted by an account migration, dropped when the container was rebuilt from an image without the crontab, disabled by a panel that rewrote the file: all three produce an empty log, and an empty log is indistinguishable from a quiet week.

The log catches the job that ran and failed. It cannot catch the job that never started, which is the failure that costs the client a backup. Closing that gap means inverting the question: instead of watching for an error to arrive, you watch for a success to stop arriving, which is the same reasoning that makes a healthy status code worthless as proof. Absence is the signal, and nothing emits it for you.

What a monitoring tool can and cannot tell you about your syntax

Nothing in our engine parses a cron expression, and that is deliberate rather than missing. There is no field for one in the schema: what gets stored is an expected interval and a grace period, in src/Heartbeat.php.

The reason is that a parser can only tell you your expression is valid, which you already know the moment it fails to install. It cannot tell you the job stopped running eleven weeks ago, which is the failure that actually costs a client. So the tool watches for the ping that does not arrive, and leaves the reading of the five fields to you and to the section above.

Share this post.
Stay up-to-date

Subscribe to our newsletter

Don't miss this

You might also like