Crontab is one of those tools that feels simple until it silently does nothing. The job is in the crontab file, the time has passed, and nothing ran — or something ran but produced no output. Most crontab failures come down to one of seven predictable causes. This guide walks through each one with a concrete diagnosis command or fix.
Before diving in: make sure output is visible by adding 2>&1 | tee /tmp/cronjob.log to your cron line. Most debugging starts by finding out what cron is actually saying.
POSIX requires every line in a crontab file to end with a newline — including the last one. If the final line has no trailing newline, some cron implementations silently ignore it. The job is never scheduled. No error is logged anywhere.
This is one of the most common causes of a crontab not running at all, and the hardest to spot visually.
# Check whether a trailing newline exists
# If the last character is NOT a newline, xxd will show no 0a at the end
crontab -l | xxd | tail -3
# Fix: open with crontab -e, go to the last line, press Enter, save
crontab -e, press Enter after the last entry, and save.
When you run a command in your shell, it searches a rich PATH that includes /usr/local/bin, /home/you/.local/bin, and wherever your tools are installed. Cron's default PATH is bare: typically just /usr/bin:/bin. A script that works perfectly in your terminal fails in cron because the executable can't be found — and you get crontab command not found in the mail spool nobody reads.
The fix is to either set PATH explicitly in the crontab or use absolute paths for every binary in your script.
# At the top of your crontab, set PATH explicitly:
PATH=/usr/local/bin:/usr/bin:/bin:/home/you/.local/bin
# Or use absolute paths in every command:
0 3 * * * /usr/bin/python3 /opt/jobs/sync.py >> /var/log/sync.log 2>&1
# Find the absolute path of a binary:
which python3
# /usr/local/bin/python3 — use that exact path in cron
Cron runs with a stripped environment. Variables like HOME, SHELL, LANG, and any custom exports you've added to ~/.bashrc or ~/.profile are not available. Scripts that read $DATABASE_URL, $API_KEY, or any custom env var will silently use empty strings — often causing a quiet failure with exit code 0.
The most reliable fix is to source your environment inside the script, or define variables directly in the crontab file above the job lines.
# Define env vars at the top of your crontab:
DATABASE_URL=postgres://user:pass@localhost/mydb
SLACK_WEBHOOK=https://hooks.slack.com/...
# Or source your profile inside the script:
#!/bin/bash
source /home/you/.profile
# ... rest of script
# Debug: print the cron environment to a file
* * * * * env > /tmp/cron-env.txt
If your cron entry calls a script directly (rather than via bash /path/to/script.sh), the script file must have the executable bit set. Without it, cron tries to execute the file, the OS refuses with Permission denied, and the job fails silently — especially if output is going to /dev/null.
This is a one-command fix that's easy to miss when you've created a new script and dropped it straight into a cron job.
# Check permissions:
ls -la /opt/jobs/myscript.sh
# -rw-r--r-- means NOT executable — cron will fail
# Fix: make it executable
chmod +x /opt/jobs/myscript.sh
# Verify:
ls -la /opt/jobs/myscript.sh
# -rwxr-xr-x — now executable
Cron expressions have five fields — minute, hour, day-of-month, month, day-of-week — and it's easy to get them wrong. A common mistake is treating the fields as hour/minute (backwards), or using */1 thinking it means "every hour" when it actually means "every minute." An invalid expression is silently skipped on many systems; a valid-but-wrong expression fires at the wrong time.
Use our free cron expression parser to verify your cron syntax before deploying — it explains exactly when your expression will fire next.
# Field order: minute hour day-of-month month day-of-week
# Common mistakes:
# WRONG: fires every minute (not every hour at minute 0)
* 0 * * * /opt/jobs/report.sh
# RIGHT: fires at midnight every day
0 0 * * * /opt/jobs/report.sh
# WRONG: "every 5 hours" — actually fires at minute 0, hours 0/5/10/15/20
0 */5 * * * /opt/jobs/report.sh
# RIGHT: every 5 minutes
*/5 * * * * /opt/jobs/report.sh
# Validate with: crontab -l | crontab - (re-reads and flags syntax errors)
If no cron jobs are running at all — not just one — the cron daemon itself may not be active. On Debian/Ubuntu the service is called cron; on RHEL/CentOS/Fedora it's typically crond. After a reboot, a failed system update, or a resource-constrained environment, the daemon can stop without obvious notification.
This is the first thing to check when nothing in crontab is running, rather than one specific job.
# Debian/Ubuntu:
systemctl status cron
# RHEL/CentOS/Fedora:
systemctl status crond
# If not running, start it:
sudo systemctl start cron # or crond
sudo systemctl enable cron # ensure it starts on reboot
# Confirm cron is active and jobs are being dispatched:
grep CRON /var/log/syslog | tail -20 # Debian/Ubuntu
grep CRON /var/log/cron | tail -20 # RHEL/CentOS
By default, cron mails stdout and stderr to the local system user. On most production servers the mail spool is never read, and many minimal Docker/cloud images don't even have a mail transport installed. The practical result: any error your script emits is lost. You see no output, assume the job ran fine, and miss the real failure entirely.
The fix is to redirect output explicitly. Do this for every cron job — it costs nothing and makes debugging immediate.
# Redirect both stdout and stderr to a log file:
0 3 * * * /opt/jobs/sync.py >> /var/log/sync.log 2>&1
# Append with timestamp to distinguish runs:
0 3 * * * echo "=== $(date) ===" >> /var/log/sync.log 2>&1; /opt/jobs/sync.py >> /var/log/sync.log 2>&1
# Suppress ALL output only when you're certain the job works:
0 3 * * * /opt/jobs/sync.py > /dev/null 2>&1
# Tail the log to watch a job in real time:
tail -f /var/log/sync.log
MAILTO="" at the top of your crontab to suppress the local mail noise, but only after you've redirected output to real log files. Never suppress output and redirect to /dev/null at the same time — that's how silent failures happen.
…the next problem is knowing when it stops running. A correctly configured crontab can still miss jobs after a reboot, a deployment, or a resource spike — and you won't know until something downstream breaks.
CronWatch sends an alert when a job is late or didn't check in. One line added to your cron job is all it takes.
Join the CronWatch waitlist → Parse a cron expression