It is 9:07 a.m. and the report that should have been in your inbox at 6:00 is not there. You SSH into the server, run the script by hand, and it works perfectly — output, exit code zero, everything. You put it back in the crontab, wait a day, and the same silence. Nothing in your logs. No error email. No crash.
This is the single most common frustration with cron, and it almost never comes from cron being broken. Cron is one of the oldest and most reliable pieces of the Unix world; it has been quietly running backups since the 1970s. What breaks is the gap between the environment you have when you type a command and the environment cron hands your script. Those are two different worlds, and the second one is much emptier than the first.
Cron does not run your script. It runs your script inside a stripped-down stranger's account that happens to share your username.
Once you internalize that sentence, ninety percent of cron mysteries solve themselves. Here is how to work through the rest, in the order that finds problems fastest.
Step one: prove cron even tried
Before debugging your script, confirm cron fired at all. There is no point tuning a recipe if the oven never turned on.
On most Linux distributions cron logs to the system journal or to a syslog file:
# systemd-based systems (Ubuntu 20.04+, Debian 11+, RHEL 8+)
journalctl -u cron --since "2 hours ago"
journalctl -u crond --since "2 hours ago" # RHEL/Fedora naming
# older or syslog-based systems
grep CRON /var/log/syslog | tail -50You are looking for a line that shows your command being started at the expected minute. Three outcomes, three very different problems:
| What you see | What it means | Where to look next |
|---|---|---|
| No line at all | Cron never scheduled it | Schedule syntax, wrong user's crontab, cron daemon not running |
| A start line, nothing else | It ran; output went nowhere | Environment, PATH, permissions — see below |
| A start line plus an error | It ran and failed loudly | Read the error; you are nearly done |
If there is no line at all, check that the daemon is alive with systemctl status cron and that your entry is where you think it is. A crontab is per user. Running crontab -l as your login user shows a completely different file from sudo crontab -l (root's) or /etc/crontab (the system-wide one, which takes an extra user column). Editing the wrong one is a genuinely common mistake, and the symptom is exactly this: total silence.
Step two: the PATH problem, which is most of them
Here is the thing that catches nearly everyone. Your interactive shell loads ~/.bashrc, ~/.profile, your version manager, your virtualenv activation — dozens of lines that quietly build a rich environment. Cron loads almost none of it. A cron job typically starts with a PATH of roughly /usr/bin:/bin and little else.
You can prove it in one minute. Add this temporary line to your crontab:
* * * * * env > /tmp/cron-env.txt 2>&1Wait sixty seconds, then cat /tmp/cron-env.txt and compare it to the env output in your own shell. The difference is usually startling — no NVM_DIR, no PYENV_ROOT, no JAVA_HOME, no /usr/local/bin, and a HOME or SHELL you did not expect.
The fix is not to fight this. It is to stop depending on the environment at all:
# fragile — depends on PATH and on which python is "first"
0 6 * * * python report.py
# robust — absolute paths, explicit working directory
0 6 * * * cd /srv/reports && /srv/reports/venv/bin/python /srv/reports/report.pyUse absolute paths for the interpreter, for the script, and for every file the script touches. A script that opens data/input.csv works when you run it from its own folder and fails under cron, which starts you in $HOME. If you need environment variables, set them explicitly at the top of the crontab or source a file inside the job itself:
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=ops@example.com
0 6 * * * . /srv/reports/.env && /srv/reports/venv/bin/python /srv/reports/report.pyStep three: capture the output, always
By default cron mails a job's output to the local user. On a modern server with no mail transport agent installed, that mail goes precisely nowhere — which is why your failing job produces no evidence at all. Never rely on it. Redirect everything to a file you control:
0 6 * * * /srv/reports/run.sh >> /var/log/reports/run.log 2>&1The 2>&1 is the important half. Without it you capture standard output but throw away standard error, and standard error is where the traceback lives. If you want the timestamp too, wrap it:
0 6 * * * /srv/reports/run.sh >> /var/log/reports/run.log 2>&1; \
echo "exit=$? at $(date -Is)" >> /var/log/reports/run.logOnce you have real logs, most of the remaining mysteries turn into ordinary bugs: a missing module, a permission denied on a directory owned by another user, a database socket at a path that only exists for a different account.
Set up log rotation while you are there, or in six months the log that saved you will be the file that fills the disk.
Step four: read the schedule field out loud
The five time fields are minute, hour, day-of-month, month, day-of-week. The classic errors are all in that order.
* * * * * command
│ │ │ │ └── day of week (0-7, 0 and 7 are Sunday)
│ │ │ └──── month (1-12)
│ │ └────── day of month (1-31)
│ └──────── hour (0-23)
└────────── minute (0-59)Three traps worth memorizing:
0 * * * * is not daily. It is every hour, on the hour — twenty-four runs a day. Daily at midnight is 0 0 * * *. People who mean "once an hour" and write * 0 * * * get sixty runs between midnight and 1 a.m. instead.
Day-of-month and day-of-week are OR, not AND. If you set both to something other than *, the job runs when either matches. 0 6 1 * 1 runs on the first of the month and every Monday, which is rarely what anyone intends.
Percent signs are special. In a crontab, an unescaped % becomes a newline and everything after the first one is fed to the command as standard input. A date format like $(date +%Y-%m-%d) silently mangles the line. Escape them as \%, or better, move the whole thing into a shell script and call the script.
That last point generalizes into a good habit: keep crontab lines boring. One absolute path to one script, one redirect. Put the logic in the script, where you can test it, version it, and read it a year later.
Step five: the timing problems that only appear in production
A job that works fine for months can start misbehaving as data grows, and the causes are rarely obvious.
Overlapping runs. If a job scheduled every five minutes starts taking six, you now have two copies running at once, competing for the same files or database rows. Cron will not stop this for you. Guard it with a lock:
*/5 * * * * /usr/bin/flock -n /tmp/sync.lock /srv/sync.sh >> /var/log/sync.log 2>&1flock -n simply exits if the lock is held, so late runs are skipped instead of stacking up.
Time zones and daylight saving. Cron follows the system time zone unless you set CRON_TZ at the top of the file. In regions that observe daylight saving, a job scheduled at 2:30 a.m. may run twice in autumn and not at all in spring. Scheduling sensitive work outside the 1–3 a.m. window avoids the whole category — or run the server in UTC and convert at the edges.
The machine was off. Classic cron makes no attempt to catch up on missed runs. If the box was rebooting at 6:00, the 6:00 job simply did not happen. anacron handles that for daily-ish jobs on laptops and intermittently powered machines, and systemd timers offer Persistent=true for the same purpose.
Which raises a fair question: should you still be using cron at all? For a single server running a handful of scripts, yes — it is universal, has no dependencies, and every ops person on earth can read it. Once you want dependency ordering, per-job resource limits, or a real record of past runs, systemd timers give you systemctl list-timers, proper journal integration, and OnFailure= hooks. Beyond that, when jobs depend on each other and need retries and backfills, you have outgrown both and want a scheduler like Airflow or a managed equivalent. Pick the smallest tool that covers the failure you actually fear.
A checklist you can run in five minutes
When a job goes quiet, work down this list in order. It resolves most cases before you reach the bottom.
journalctl -u cron --since today— did cron start it?crontab -las the right user — is the entry where you think it is?- Is every path in the line absolute, including the interpreter?
- Does the line end with
>> /path/to/log 2>&1? - Run it the way cron would:
env -i /bin/sh -c '/full/path/to/script.sh'— does it still work with an empty environment? - Is the script executable, and readable by the crontab's owner?
- Any unescaped
%in the line? - Could two runs be overlapping? Add
flock.
Step five is the one people skip and the one that finds the bug. env -i strips the environment down to nothing, which is much closer to what cron gives you than your comfortable interactive shell.
The short version
Cron is not mysterious, it is just minimal. It hands your script an almost empty environment, starts it in a directory you did not choose, and throws away the output unless you catch it. Write your jobs assuming none of your setup exists: absolute paths everywhere, an explicit cd, a redirect to a real log file, and a lock if the job could ever run long.
Do that once, and the 6 a.m. report shows up at 6 a.m. — and on the rare morning it does not, you will have a log file that tells you exactly why instead of a silence that tells you nothing.


