DEV Community

Super Funicular
Super Funicular

Posted on

A Crontab Line That Works in Your Terminal Fails Under cron: What % Means in a Crontab

I wanted a nightly crontab line that archives a folder of camera clips into a file named for the date: tar czf backup/clips-$(date +%F).tgz clips. Typed into a terminal, it made clips-2026-10-29.tgz. Pasted into a crontab, the same text would not make a file, and nothing about the line looks wrong.

The reason is one character. In a crontab, % is not plain text.

Disclosure: I make an Android camera app. Nothing below uses its code or describes how it stores or backs up files. I could not run the cron daemon in this environment (it needs root, and I do not have it here), so I did the next best thing: I took the rules the Debian crontab(5) manual page states, applied them with a short script, and ran the result through /bin/sh the way cron would. Read every cron result below as the manual's description of cron, not as a test of the daemon itself.

What the tests showed:

  • An unescaped % ends the command. Everything after it becomes standard input. A date format string cuts the line in half.
  • The shell then sees half a command and fails with a syntax error, so no archive is written.
  • \% fixes it, but inside double quotes the backslash may survive into the output.
  • Two day fields are ORed, not ANDed. A first-Sunday-of-the-month line runs on eleven days in November 2026, not one.
  • A day field that starts with * changes that rule on Debian's cron, which is not what POSIX says.
  • PATH=$HOME/bin:$PATH in a crontab is taken literally, so every command on the line after it goes missing.

Setup

#!/bin/bash
# Scratch folder with two clips and a backup target.
set -eu
D=/tmp/cron_demo
rm -rf "$D"; mkdir -p "$D/clips" "$D/backup"
head -c 4096 /dev/urandom > "$D/clips/clip_0001.mp4"
head -c 4096 /dev/urandom > "$D/clips/clip_0002.mp4"
uname -sr
readlink -f /bin/sh
bash --version | head -1
python3 --version
Enter fullscreen mode Exit fullscreen mode
Linux 6.8.0-138-generic
/usr/bin/dash
GNU bash, version 5.1.16(1)-release (aarch64-unknown-linux-gnu)
Python 3.10.12
Enter fullscreen mode Exit fullscreen mode

/bin/sh here is dash. The manual says cron runs commands with /usr/bin/sh unless the crontab sets SHELL, and on Debian and Ubuntu that is normally dash too.

The emulator

This is the whole of the cron behaviour I am relying on. It follows the manual's sentence: "Percent-signs (%) in the command, unless escaped with backslash (), will be changed into newline characters, and all data after the first % will be sent to the command as standard input."

#!/usr/bin/python3
# Applies the crontab(5) rule for the command field, then runs it:
#   text up to the first unescaped % goes to the shell,
#   every later unescaped % becomes a newline,
#   everything after the first % is sent to the command as standard input.
# A backslash before % is kept here; the shell removes it (see below).
import subprocess, sys

def split(field):
    cmd, stdin, cur, i, seen = "", "", [], 0, False
    while i < len(field):
        c = field[i]
        if c == "\\" and field[i+1:i+2] == "%":
            cur.append("\\%"); i += 2; continue
        if c == "%":
            if not seen:
                cmd, cur, seen = "".join(cur), [], True
            else:
                cur.append("\n")
        else:
            cur.append(c)
        i += 1
    if seen:
        stdin = "".join(cur)
    else:
        cmd = "".join(cur)
    return cmd, stdin

field = sys.argv[1]
cmd, stdin = split(field)
print("shell gets : %s" % cmd)
print("stdin gets : %r" % stdin)
r = subprocess.run(["/bin/sh", "-c", cmd], input=stdin, text=True,
                   capture_output=True, cwd="/tmp/cron_demo")
print("exit status: %d" % r.returncode)
for line in (r.stdout + r.stderr).splitlines():
    print("output     : %s" % line)
Enter fullscreen mode Exit fullscreen mode

Test one: the date in the file name

#!/bin/bash
cd /tmp/cron_demo
LINE='tar czf backup/clips-$(date +%F).tgz clips'
echo "--- typed in a terminal"
bash -c "$LINE"; echo "exit status: $?"; ls backup
rm -f backup/*
echo "--- the same text as a crontab command field"
python3 /tmp/cron_demo_code/cronsplit.py "$LINE"
ls backup | wc -l
Enter fullscreen mode Exit fullscreen mode

Output:

--- typed in a terminal
exit status: 0
clips-2026-10-29.tgz
--- the same text as a crontab command field
shell gets : tar czf backup/clips-$(date +
stdin gets : 'F).tgz clips'
exit status: 2
output     : /bin/sh: 1: Syntax error: end of file unexpected (expecting ")")
0
Enter fullscreen mode Exit fullscreen mode

In the terminal the line worked. As a crontab command, the shell received tar czf backup/clips-$(date + and nothing else. The rest, F).tgz clips, went to standard input, which tar ignores here. Dash reported a syntax error and exited 2, and the backup folder stayed empty.

On a real system the error text would not reach your terminal either. The manual says "Command output is mailed to the crontab owner". If the machine has no working mail setup, I do not know where that message ends up, and I did not test it. The practical result is the same: a backup job that has not produced a single file, and nothing on screen saying so.

Test two: escaping it

#!/bin/bash
cd /tmp/cron_demo
rm -f backup/*
LINE='tar czf backup/clips-$(date +\%F).tgz clips'
python3 /tmp/cron_demo_code/cronsplit.py "$LINE"
ls backup | sed '/^output/s/[0-9]/N/g'
echo "--- the same escape inside double quotes"
python3 /tmp/cron_demo_code/cronsplit.py 'date "+\%F"' | sed '/^output/s/[0-9]/N/g'
Enter fullscreen mode Exit fullscreen mode

Output:

shell gets : tar czf backup/clips-$(date +\%F).tgz clips
stdin gets : ''
exit status: 0
clips-2026-10-29.tgz
--- the same escape inside double quotes
shell gets : date "+\%F"
stdin gets : ''
exit status: 0
output     : \NNNN-NN-NN
Enter fullscreen mode Exit fullscreen mode

With \% outside quotes the archive was written. The shell's own rule removes a backslash in front of an ordinary character, so +\%F reaches date as +%F whether or not cron strips the backslash first.

Inside double quotes it is different. A backslash before % is not special there, so the shell keeps it, and date printed a literal backslash in front of the date. The manual says to escape % with a backslash but does not say whether cron removes that backslash before the shell sees it. My emulator keeps it, which is the worse case. I did not test which one Debian's cron actually does, so I keep the escaped % outside quotes, where both cases give the same answer.

Why % works this way

It is a feature, not an accident. The manual's own example file uses it to write a mail body on one line: mail -s "It's 10pm" joe%Joe,%%Where are your kids?%. The same mechanism, smaller:

#!/bin/bash
# % used on purpose, as in the manual's mail example: lines on standard input.
python3 /tmp/cron_demo_code/cronsplit.py 'cat -n%first line%second line%'
Enter fullscreen mode Exit fullscreen mode
shell gets : cat -n
stdin gets : 'first line\nsecond line\n'
exit status: 0
output     :      1 first line
output     :      2 second line
Enter fullscreen mode Exit fullscreen mode

That is useful for a one-line mail or a short input, and it is the reason a date format in a crontab needs care.

Test three: the two day fields

A crontab has two day fields, day of month and day of week. A common first try at 03:00 on the first Sunday of the month is 0 3 1-7 * sun. The manual: "If both fields are restricted (i.e., aren't *), the command will be run when either field matches the current time."

There is a second rule hiding in the same page. Debian's cron "only checks if the first character is a ", so a field like `/2 counts as unrestricted, and then both fields have to match. POSIX would treat */2` as restricted.

This script lists the November 2026 days each line would match, under Debian's rule and under the POSIX one:

#!/usr/bin/python3
# Which days in November 2026 match a crontab's two day fields?
# Rule from crontab(5): if both day fields are restricted, run when EITHER
# matches. Debian's cron decides "restricted" by the field's first character
# only: a field starting with * counts as unrestricted, so the two are ANDed.
import calendar, datetime

DOW = {"sun":0,"mon":1,"tue":2,"wed":3,"thu":4,"fri":5,"sat":6}

def expand(field, lo, hi, names=None):
    out = set()
    for part in field.split(","):
        step = 1
        if "/" in part:
            part, s = part.split("/"); step = int(s)
        if part == "*":
            a, b = lo, hi
        elif "-" in part:
            a, b = part.split("-")
            a = int(names.get(a, a)) if names else int(a)
            b = int(names.get(b, b)) if names else int(b)
        else:
            a = b = int(names.get(part, part)) if names else int(part)
        out |= set(range(a, b + 1, step))
    return {0 if v == 7 else v for v in out} if names else out

def days(dom, dow, rule):
    D = expand(dom, 1, 31); W = expand(dow, 0, 7, DOW)
    hit = []
    for d in range(1, calendar.monthrange(2026, 11)[1] + 1):
        wd = (datetime.date(2026, 11, d).weekday() + 1) % 7   # Sunday = 0
        a, b = d in D, wd in W
        if rule == "debian":
            star_dom, star_dow = dom[0] == "*", dow[0] == "*"
        else:  # POSIX: unrestricted only if the field IS *
            star_dom, star_dow = dom == "*", dow == "*"
        ok = (a and b) if (star_dom or star_dow) else (a or b)
        if ok: hit.append(d)
    return hit

for dom, dow in [("1-7", "sun"), ("1-7", "*"), ("*/2", "sun")]:
    for rule in ("debian", "posix"):
        h = days(dom, dow, rule)
        print("%-4s %-3s %-6s -> %2d runs: %s" % (dom, dow, rule, len(h),
              " ".join(str(x) for x in h)))
Enter fullscreen mode Exit fullscreen mode

Output:

1-7  sun debian -> 11 runs: 1 2 3 4 5 6 7 8 15 22 29
1-7  sun posix  -> 11 runs: 1 2 3 4 5 6 7 8 15 22 29
1-7  *   debian ->  7 runs: 1 2 3 4 5 6 7
1-7  *   posix  ->  7 runs: 1 2 3 4 5 6 7
*/2  sun debian ->  3 runs: 1 15 29
*/2  sun posix  -> 17 runs: 1 3 5 7 8 9 11 13 15 17 19 21 22 23 25 27 29
Enter fullscreen mode Exit fullscreen mode

The first-Sunday line matches eleven days: every day from the 1st to the 7th, plus every Sunday in the month. Only one of those is the day I meant. The */2 sun line shows the first-character rule: three runs under Debian's reading, seventeen under the POSIX one. The manual describes that same line the same way: it "runs every Sunday that's an uneven date while the POSIX standard would have it run every Sunday and on every uneven date." The same line can mean a different schedule on a different cron.

The fix the manual suggests

Use one day field and put the other test in the command. The manual's own example does this for the second Saturday, and its test uses date +\%u, escaped, for the reason in test one.

#!/bin/bash
# The fix the manual suggests: one day field, plus a test inside the command.
# Here the test runs for each of November 1-7, 2026 instead of waiting for cron.
cd /tmp/cron_demo
for d in 01 02 03 04 05 06 07; do
  LINE="test \$(date -d 2026-11-$d +\%u) -eq 7 && echo 2026-11-$d would run"
  python3 /tmp/cron_demo_code/cronsplit.py "$LINE" | grep -E '^output'
done
Enter fullscreen mode Exit fullscreen mode

Output:

output     : 2026-11-01 would run
Enter fullscreen mode Exit fullscreen mode

Of the seven days the line would wake up in that week, the test let exactly one through.

Test four: a PATH line

Crontabs accept NAME=value lines, and it is natural to write PATH=$HOME/bin:$PATH. The manual says the value "is not parsed for environmental substitutions or replacement of variables or tilde(~) expansion", and gives that exact line as one that "will not work as you might expect".

#!/bin/bash
# crontab(5): values are not expanded, so PATH=$HOME/bin:$PATH is taken literally.
cd /tmp/cron_demo
env -i 'PATH=$HOME/bin:$PATH' /bin/sh -c 'echo "PATH is: $PATH"; tar --version'
echo "exit status: $?"
env -i 'PATH=/usr/local/bin:/usr/bin:/bin' /bin/sh -c 'tar --version' | head -1
echo "exit status: $?"
Enter fullscreen mode Exit fullscreen mode

Output:

PATH is: $HOME/bin:$PATH
/bin/sh: 1: tar: not found
exit status: 127
tar (GNU tar) 1.34
exit status: 0
Enter fullscreen mode Exit fullscreen mode

With the literal value, PATH held the text $HOME/bin:$PATH, no real directory, and tar was not found (exit 127). Writing the directories out in full fixed it. The manual also offers an eval workaround (P=PATH=/a/b/c:$PATH, then eval $P && some commands in the command field), which I did not test.

What to do instead

  • Escape every % in a crontab command as \%, and keep it outside double quotes.
  • Better, move anything with a date format into a script and have the crontab call the script. The script is ordinary shell, and % means nothing special there.
  • For a rule like the first Sunday of the month, restrict one day field and test the other inside the command.
  • Start a day field with * only when you mean any day for that field.
  • Write PATH= in full, with real directories.
  • After installing a new line, check the next morning that the file it should write exists. A job that fails at the shell stage leaves no file and no error on your screen.

Things I did not test

  • The cron daemon itself. Every cron result here is the manual's rule applied by a script. A different cron build could behave differently.
  • cronie, BusyBox crond, systemd timers. Their manuals may differ, and BusyBox crond was not checked at all.
  • Whether cron strips the backslash in \%. I worked around it rather than answering it.
  • Mail delivery of the error. Not tested.

Sources. Quotes are from the Debian crontab(5) manual page (cron 3.0pl1-212). Results come from the scripts above, each run once, with output pasted unedited.

If you want an old phone recording clips in the background so there is a folder worth backing up every night, the app I make is Background Camera RemoteStream on Google Play. Nothing above depends on it.

The line was fine as shell. It just was not shell yet.

Top comments (0)