Cron accepts schedules from more than one kind of table. A user crontab belongs to one account, while system tables can name the account for each entry. Copying a line between them without adjusting its fields changes what cron interprets as the command. Identify the table format before debugging an apparently correct five-field calendar expression.
The account column changes the parser boundary
A conventional user entry contains minute, hour, day of month, month, day of week, then command text. The crontab command installs it for the selected user. Debian’s /etc/crontab and files under /etc/cron.d add a username after the five schedule fields. That username belongs to the system-table format; it is not an optional annotation in a user’s command.
The month constraint remains required. When both day-of-month and day-of-week fields are restricted, the Debian/Vixie day rule permits either day match; month and weekday are not alternatives. Check the scheduler’s documented dialect before transferring expressions to products with seconds fields or different day rules. Familiar-looking spacing does not establish compatible semantics.
System drop-ins have ownership, permission, filename, and final-newline requirements. On Debian, use root-owned files that are not group- or other-writable and accepted names rather than arbitrary backup suffixes. Periodic directories such as /etc/cron.daily are invoked through the distribution’s scheduling arrangement, commonly using run-parts; every script is not independently parsed as a crontab. Inspect the installed arrangement before deciding when it runs.
When it breaks
Putting root after the five fields in root’s user crontab asks the shell to execute a command named root. Installation success cannot prove that this command exists. Conversely, omitting the account from a system entry moves command text into the username position. Review both the parser boundary and the effective identity, especially when the job writes files or accesses protected data.
The job environment is also a contract. Do not assume interactive aliases, shell startup files, or variables from the daemon’s parent shell are available. Set required values explicitly and choose an intentional PATH and interpreter. Environment settings in a cron.d file should not be assumed to inherit those in /etc/crontab. Test permissions and outputs as the actual execution account.
Percent signs have special handling before the shell sees the complete command. Unescaped percent characters delimit standard input, with subsequent percents becoming newlines. Shell quoting alone does not remove cron’s interpretation. Keep complex commands in scripts and escape literal percent signs when they must appear directly in a table entry. Separate scheduling syntax checks from command execution and application outcome checks.
Check it quickly
Use a fresh disposable Debian 12 Docker container as root with python3, cron, procps, and passwd installed. Ensure it has no active cron daemon, root crontab, or account named xbtime. Save this as cron-tables.py and run python3 cron-tables.py. It creates only its temporary account, task table, and files, waits up to seventy-five seconds for a real scheduled minute, then removes them.
from pathlib import Path
from tempfile import TemporaryDirectory
import json
import os
import pwd
import shutil
import subprocess
import time
assert os.geteuid() == 0 and Path('/.dockerenv').exists(), 'use a disposable root Linux container'
assert subprocess.run(['pgrep', '-x', 'cron'], capture_output=True).returncode == 1
assert subprocess.run(['crontab', '-l'], capture_output=True).returncode != 0
assert shutil.which('root') is None
username = 'xbtime'
try:
pwd.getpwnam(username)
except KeyError:
pass
else:
raise AssertionError('test account already exists')
system_table = Path('/etc/cron.d/xbcatm_time_test')
assert not system_table.exists()
subprocess.run(['useradd', '--create-home', '--shell', '/bin/sh', username], check=True)
account = pwd.getpwnam(username)
daemon = None
try:
with TemporaryDirectory(prefix='cron-tables-') as directory:
root = Path(directory)
os.chown(root, account.pw_uid, account.pw_gid)
worker = root / 'worker.py'
worker.write_text('''from pathlib import Path
import json,os,sys
root=Path(__file__).parent
mode=sys.argv[1]
data={'uid':os.getuid(),'table':os.environ.get('LAB_TABLE'),
'path':os.environ.get('PATH'),'shell':os.environ.get('SHELL'),
'parent_only':os.environ.get('PARENT_ONLY')}
if mode=='percent': data['stdin']=sys.stdin.read()
(root/(mode+'.json')).write_text(json.dumps(data))
''')
user_table = ('SHELL=/bin/sh\nPATH=/usr/bin:/bin\nLAB_TABLE=user\n'
f'* * * * * /usr/bin/python3 {worker} user\n'
f'* * * * * /usr/bin/python3 {worker} percent%first%second\n'
f'* * * * * root /usr/bin/id >{root}/wrong-column.err 2>&1\n')
subprocess.run(['crontab', '-'], input=user_table, text=True, check=True)
invalid = subprocess.run(['crontab', '-'], input='* * * * * /bin/true',
text=True, capture_output=True)
assert invalid.returncode != 0
assert subprocess.check_output(['crontab', '-l'], text=True) == user_table
system_table.write_text('SHELL=/bin/sh\nPATH=/usr/bin:/bin\nLAB_TABLE=system\n'
f'* * * * * {username} /usr/bin/python3 {worker} system\n')
system_table.chmod(0o644)
daemon = subprocess.Popen(['cron', '-f'], env={**os.environ, 'PARENT_ONLY': 'parent-demo'},
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
deadline = time.monotonic() + 75
names = ['user.json', 'system.json', 'percent.json', 'wrong-column.err']
while time.monotonic() < deadline:
assert daemon.poll() is None
if all((root/name).exists() and (root/name).stat().st_size for name in names):
break
time.sleep(0.2)
else:
raise AssertionError('scheduled table outputs were not produced')
user = json.loads((root/'user.json').read_text())
system = json.loads((root/'system.json').read_text())
percent = json.loads((root/'percent.json').read_text())
wrong = (root/'wrong-column.err').read_text()
assert user['uid'] == 0 and user['table'] == 'user'
assert system['uid'] == account.pw_uid and system['table'] == 'system'
assert all(value['parent_only'] is None for value in (user, system))
assert all(value['path'] == '/usr/bin:/bin' for value in (user, system))
assert percent['stdin'] == 'first\nsecond\n'
assert 'root: not found' in wrong
print(json.dumps({'user_table': user, 'system_table': system,
'missing_final_newline_rejected': invalid.returncode,
'previous_table_preserved': True, 'percent_stdin': percent['stdin'],
'username_in_user_table_fails': wrong.strip()}), flush=True)
finally:
daemon.terminate()
daemon.communicate(timeout=5)
daemon = None
finally:
if daemon is not None:
daemon.terminate()
daemon.communicate(timeout=5)
system_table.unlink(missing_ok=True)
subprocess.run(['crontab', '-r'], capture_output=True)
subprocess.run(['userdel', '--remove', username], capture_output=True, check=True)
The user-table job runs as UID zero. The system-table job runs as the newly created account, UID 1000 in this run. Each receives its own LAB_TABLE value and explicit PATH; neither receives the daemon parent’s PARENT_ONLY variable. A percent-bearing entry supplies first\nsecond\n on standard input. The wrongly inserted username produces root: not found, despite its table installing successfully.
An attempted replacement missing its final newline is rejected with exit one, and the previous table remains intact. All assertions passed on cron 3.0pl1-162. Separate probes found that this package accepted malformed minute text even with its dry-run option, so no universal field-validation guarantee is inferred from a zero exit status. The exercise verifies observed account, environment, parsing, and installation behavior; it does not test mail delivery, daylight-saving handling, or business success. Keep those additional requirements visible when deploying a real scheduled task.
Sources
- Debian 12: crontab(5), user/system table syntax, environment and percent handling, checked 2026-09-13.
- Debian 12: cron(8), system drop-ins, ownership and execution environment, checked 2026-09-13.
- Debian 12: crontab(1), installation, dry-run behavior and newline requirements, checked 2026-09-13.