Why Didn't My Cron Job Run? Debugging Silent cron Failures
Debug cron jobs that never fire or fail silently — % escaping, the minimal cron environment, PATH/cwd traps, mail output, logs and DST.
Cron’s failure mode is silence. The daemon doesn’t report errors to a console — it either runs your command or it doesn’t, mails the output to a local mailbox nobody reads, and logs one thin CMD line into syslog. So “my cron job didn’t run” splits into three different bugs: the line never parsed, the schedule never matched, or the job ran and failed where nobody saw. Work them in that order.
1. Did cron even see the line?
crontab -l # is it actually installed?
grep CRON /var/log/syslog | tail # Debian/Ubuntu: CMD lines per firing
journalctl -u cron --since today # systemd systems
journalctl -u crond --since today # RHEL/Fedora name
If there’s no CMD line for your job at the expected minute, cron never got that far:
- Wrong file, wrong shape.
/etc/crontaband/etc/cron.d/need a user field between schedule and command (0 2 * * * root cmd).crontab -efiles don’t — addingrootthere runs a command literally named “root”. This is the most common “runs nothing” cause. - The file didn’t reload. Edits via
crontab -eare picked up automatically; hand-editing/var/spool/cron/files directly may not be. Usecrontab -e, ortouchnothing and trust/etc/cron.d/which is re-scanned. - User is gated out.
/etc/cron.allow//etc/cron.denycan exclude the account —crontab -lwill say so. - Syntax rejected the line. A bad field (
61 * * * *, a stray#mid-line in some parsers) can make the daemon skip the entry. Paste the schedule into the calculator — it names the offending field and position.
2. The schedule matched — but did the time exist?
If CMD lines appear but at surprising times, suspect the schedule itself:
- The dom/dow OR rule.
0 0 13 * 5runs every Friday and every 13th — see the OR trap. If your job ran “early”, count again with OR semantics. - Day-of-month overflow is silent.
0 0 31 * *just skips February — no error, no log, no run.29in February means leap years only. - Timezone. Cron schedules in the system’s local time. Server UTC, you in UTC+8 —
0 9 * * *fires at 09:00 server time. Checktimedatectl. Some crons supportCRON_TZ=/TZ=lines above entries. - DST. Jobs inside the spring-forward gap are skipped or run late depending on the daemon; jobs in the fall-back overlap can fire twice. This is genuinely implementation-defined — if it matters, schedule outside 02:00–03:00 or use UTC.
- The machine was off. Cron is not anacron: a missed 03:00 on a powered-down laptop is gone, not queued. For “catch up missed runs” you want anacron,
cron.daily, or a systemd timer withPersistent=true— see systemd timers vs cron.
3. It ran and failed — silently
CMD logged at the right minute means cron did its job. What remains is why the command died in cron’s barren environment:
The minimal environment. Cron gives you roughly HOME, LOGNAME, PATH=/usr/bin:/bin, SHELL=/bin/sh. Your interactive shell’s .bashrc, .profile, exported variables, nvm/pyenv shims — none of it exists. Symptoms: command not found for tools in /usr/local/bin, node/python resolving to a different version, config files not found in ~/. Fixes, in order of preference:
# set what you need explicitly, at the top of the crontab
PATH=/usr/local/bin:/usr/bin:/bin
DATABASE_URL=postgres://…
0 3 * * * /usr/local/bin/backup.sh
# or wrap in a login shell inside the script
#!/bin/bash -l # loads your profile — heavier but predictable
Working directory. Jobs start in the user’s home (or / for system entries), not where the script lives. Any relative path — ./config.yml, ../data/ — breaks. cd first: 0 3 * * * cd /opt/app && ./run.sh.
The % trap. Unescaped % in a crontab command is a newline: everything after it is piped to the command’s stdin. date +%Y-%m-%d runs date + and feeds Y-… to stdin. Escape every one: date +\%Y-\%m-\%d. This produces “works in shell, dies in cron” with a baffling error and is the single most under-diagnosed cron bug.
Shell differences. SHELL=/bin/sh means no [[ ]], no source, no arrays — write POSIX or set SHELL=/bin/bash.
Output went to local mail. Cron mails stdout/stderr to the owner (MAILTO= overrides). No MTA installed → output discarded → “silent”. Either install one, or redirect yourself and keep the log:
0 3 * * * /opt/jobs/nightly.sh >>/var/log/nightly.log 2>&1
Permissions and locks. Script not executable (chmod +x), reads a file the cron user can’t, or overlaps with its own still-running previous instance — add a flock: */10 * * * * flock -n /tmp/job.lock /opt/job.sh.
The five-minute triage
crontab -lshows the line? → no: wrong file/user field.grep CRON /var/log/syslogshows aCMDat the right minute? → no: schedule/tz/never-fires — paste the fields into the calculator.- Still nothing? → it’s the environment: PATH, cwd,
%, missing env vars. Run the job as cron would:env -i SHELL=/bin/sh PATH=/usr/bin:/bin HOME=$HOME /bin/sh -c 'your command'. - Redirect
>> log 2>&1so the next silence is at least on record.
The meta-rule: cron will never tell you what went wrong — instrument the job so you don’t have to ask it twice.
Frequently asked questions
The job works from my shell but not from cron — why?
Almost always the environment. Cron runs jobs with a tiny default env: PATH=/usr/bin:/bin-ish, no .bashrc/.profile, different HOME/cwd on some systems, no display. Anything your command relies on implicitly — a tool in /usr/local/bin, an env var, a relative path — can be missing. Fix: absolute paths everywhere, or set PATH and the needed variables at the top of the crontab.
Where did the job's output go?
Into local mail, if a mail agent is installed — cron mails stdout/stderr to the crontab owner (or MAILTO). On servers without MTA the output is discarded, which is why failures feel silent. Check /var/mail/$USER, and better: redirect yourself — 0 3 * * * job >>/var/log/myjob.log 2>&1.
My command has % in it and cron does something weird.
In crontab, an unescaped % is a newline — cron splits the command there and pipes the rest to the command's stdin. date +%F in a crontab runs date + and feeds "F" to its stdin. Escape every percent: date +\%F. This bites hardest in date calls and URL-encoded curl strings.
crontab -e vs /etc/crontab — what's the difference?
System crontabs (/etc/crontab, /etc/cron.d/*) have a user field between schedule and command: 0 2 * * * root job. User crontabs (crontab -e) don't — the job runs as the file's owner. Paste a user-crontab line into /etc/crontab and cron reads your command as the username.
Does cron log when jobs run?
Starts, yes — usually. grep CRON /var/log/syslog (Debian/Ubuntu) or journalctl -u cron / -u crond (systemd systems) shows CMD lines per firing. Whether it succeeded is not cron's problem — that's your logging.
Can a user be blocked from cron entirely?
Yes — /etc/cron.allow and /etc/cron.deny, plus PAM/account rules. If crontab -l says "not allowed", that's the gate. A deleted/locked service account can also have its crontab silently skipped.