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.

Published 2026-09-25

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/crontab and /etc/cron.d/ need a user field between schedule and command (0 2 * * * root cmd). crontab -e files don’t — adding root there runs a command literally named “root”. This is the most common “runs nothing” cause.
  • The file didn’t reload. Edits via crontab -e are picked up automatically; hand-editing /var/spool/cron/ files directly may not be. Use crontab -e, or touch nothing and trust /etc/cron.d/ which is re-scanned.
  • User is gated out. /etc/cron.allow / /etc/cron.deny can exclude the account — crontab -l will 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 * 5 runs 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. 29 in 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. Check timedatectl. Some crons support CRON_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 with Persistent=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

  1. crontab -l shows the line? → no: wrong file/user field.
  2. grep CRON /var/log/syslog shows a CMD at the right minute? → no: schedule/tz/never-fires — paste the fields into the calculator.
  3. 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'.
  4. Redirect >> log 2>&1 so 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.