The short answer: a cron job fails silently when it never runs, exits before finishing, or errors without anyone seeing the output. Cron only emails output if a mail transfer agent is configured and
MAILTOis set — otherwise errors vanish. The only reliable fix is push-based monitoring: the job pings an external service on success, and you are alerted when the ping does not arrive.
There is a kind of outage with no alert attached to it. The dashboard is green, the host is up, nothing paged anyone — and the nightly export has not produced a file in eleven days. Nobody broke anything. Cron did exactly what it was designed to do, which is considerably less than most people assume.
Here is the full chain, traced to the cron source tree rather than folklore.
The quick checklist#
Most silent failures are one of these:
- Cron never reads your exit code. It runs the command and discards the status.
- No mail server is installed. Cron hands output to a mailer that is not there.
MAILTOis unset or empty. Even with a mail server, mail lands in a mailbox nobody reads.- Cron searches different folders than you do. A command that works in your terminal is not found.
- Output goes to
/dev/null.>/dev/null 2>&1silences errors along with everything else. - The machine was off. Cron does not run missed jobs — it skips them permanently.
- The cron service was not running. A stopped daemon produces no errors at all.
- The script exited
0despite a real failure. An early command failed and it carried on. - The job hung and never finished. Cron has no built-in timeout.
- Two runs overlapped. A slow job on a tight schedule stacks on top of itself.
The first three turn a failure into a silent failure. The rest are ordinary bugs with no reporting channel attached.
Cron does not read your exit code#
Standard cron does not act on a non-zero exit status. It does not log a failure, does not retry, and has no concept of a bad run. It mails whatever the command wrote to stdout and stderr — output, not status.
Thus, your job's exit code is read, printed in a debug trace, and then discarded — in do_command.c it is used in exactly one place, inside a Debug() macro. That is not an oversight, it is the contract. The Symfony team's croncape wrapper puts it this way:
Checking the exit code would be better, but that's not how crontab was standardized.
— croncape, SymfonyCorp
So cron's failure signal is chattiness, not status. A job that fails loudly gets mailed. One that fails quietly produces nothing. This means that you cannot catch the cases when system was down due to an OOM issue or the job had not produced a loud error.
The best you can do at this layer is set the environment explicitly and use absolute paths:
MAILTO=ops@example.com
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * /usr/bin/python3 /srv/app/nightly.py
Better than nothing, but not detection.
MAILTO only works if you have a mail server#
crontab(5) is precise about the mail rules:
If
MAILTOis defined (and non-empty), mail is sent to the user so named. IfMAILTOis defined but empty (MAILTO=""), no mail will be sent. Otherwise mail is sent to the owner of the crontab.
— crontab(5), Vixie cron
Note what it does not say. Cron will look at MAILTO; nothing promises delivery. That is somebody else's job — cron(8): "The default mailer command is /usr/sbin/sendmail."
On a stock Ubuntu, Debian, or Amazon Linux cloud image, /usr/sbin/sendmail is not there — minimal images have shipped without a mail transfer agent (MTA) for years. Thus, the message is composed and dropped.
Where an MTA does exist, the outcome is slightly better — mail goes to /var/mail/$USER, an mbox nobody has opened in years. If you have ever inherited a server, you may know how it feels to open that file and see years of cron output no one has read.
Even if you have an MTA on your server and everything is configured perfectly, there is still a major gap. Mail only arrives when the job produced output. A job that never ran produces none, so it generates no mail. You cannot tell "ran fine" from "never ran" by looking at an empty inbox.
It works when you run it, but not from cron#
You log in, run the command by hand, it works. Cron runs the same line at 3am and nothing happens.
Cron gives your job a far emptier environment than you get when you log in. Opening a terminal loads your setup files — .bashrc, .profile — and those are what put tools like nvm, pyenv, or an activated virtualenv within reach. Cron reads none of them.
The biggest troublemaker is PATH: the list of folders searched when you type a command name. Cron does set it, just not to yours. In the cron source it is fixed at /usr/bin:/bin.
Two folders. Not /usr/local/bin, where Homebrew and most hand-installed tools live, and not the folders nvm or pyenv add. So node, python3, or psql can work when you type it and fail under cron with command not found.
Then it compounds. That error goes to stderr, cron tries to mail it, there is no mail server, the message is dropped. The job "ran" nightly for a month and did nothing.
So do not rely on the environment: give every program its full path (/usr/bin/python3, not python3), set PATH= at the top of the crontab, and cd into the directory your script expects.
You redirected your own errors away#
The most-copied line in cron tutorials guarantees silence:
0 3 * * * /srv/app/nightly.sh >/dev/null 2>&1
>/dev/null discards stdout; 2>&1 then sends stderr to the same place. Every stack trace and disk-full error is deleted before anything can act on it.
People write it for a reason that is not stupid: a chatty job otherwise mails you on every successful run, and a mailbox that alerts you 365 times a year is one you filter away. It trades a useless signal for none.
The narrower fix is >/dev/null alone, letting stderr through. Wrappers like croncape and cronic emit output only on a non-zero exit. Both still route into a mail system that may not exist, and neither helps when the job never ran.
The machine was off, or the daemon was not running#
Cron assumes the machine is always on. If the host was down at 03:00, that job did not run, will not be made up, and nothing records that it was skipped.
anacron exists for this gap, catching up overdue jobs on reboot — at daily granularity, so it is no help hourly. On systemd it is one line:
[Timer]
OnCalendar=daily
Persistent=true
Persistent=true makes it store the last trigger time on disk, which the systemd docs describe as useful "to catch up on missed runs of the service when the system was powered down."
Also, check separately that the scheduler is running at all: systemctl status cron on Debian and Ubuntu, crond on RHEL and Fedora. A stopped daemon is invisible from the job's side — no process, no error.
The script "succeeded" and did nothing#
A script without set -e continues past a failed command and exits with the status of whatever ran last, usually something trivial that succeeded. In a pipeline the shell reports only the last command's status, so a failed curl feeding a working gzip gives a clean 0 and an archive full of error page.
set -euo pipefail fixes the common cases, and ${PIPESTATUS[@]} tells you which stage broke. Do not treat set -e as complete, though — it is suppressed inside conditionals and || chains and behaves differently in subshells. And every language has the same bug in its own dialect: a bare except: in Python, a swallowed promise rejection in Node. No scheduler sees through any of them, because from the outside the process exited zero.
The run hung, or two runs overlapped#
Cron has no built-in timeout, so a job blocking on a network call sits there indefinitely while the next run starts on top of it. This can happen if you have an infinite loop that never terminates or when your cron job takes more time than its frequency. timeout "runs the given command and kills it if it is still running after the specified time interval," exiting 124. flock -n makes runs mutually exclusive, failing "rather than wait if the lock cannot be immediately acquired":
*/5 * * * * /usr/bin/flock -n /tmp/sync.lock /usr/bin/timeout 240 /srv/app/sync.sh
Both are first-class configuration on newer schedulers. A systemd service takes RuntimeMaxSec= and will not start twice concurrently. A Kubernetes CronJob takes both directly:
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
activeDeadlineSeconds: 240
Modern schedulers fixed the scheduling, not the reporting#
Half of the above reads as an argument for replacing cron, and it is one. systemd timers and Kubernetes CronJobs solve what cron leaves to you: catch-up, deadlines, concurrency, retries, real logs instead of mail. If you are writing defensive bash for behaviour your scheduler offers as configuration, move.
It still does not close the gap, and the Kubernetes docs are honest about why:
The scheduling is approximate because there are certain circumstances where two Jobs might be created, or no Job might be created.
— CronJob, Kubernetes documentation
"No Job might be created" is also a silent failure. Every scheduler shares the limitation: it knows whether it started something, not whether the thing worked, and it is not watching for the answer. A CronJob whose pod exits zero after skipping every record is a green CronJob.
Uptime monitoring cannot cover for it either. An HTTP check proves an endpoint responded. Your API can serve 200 for weeks while your nightly export has not run once, because the two are unrelated and there is no endpoint that means "the 03:00 job finished." The only thing that knows a run succeeded is the run itself. More on that in why uptime monitoring isn't enough.
The fix: push-based confirmation#
Invert the direction. The job reports in when it finishes, and the absence of that report is the alert. That is a dead man's switch, and it covers the whole checklist.
Machine off, daemon dead, command not found, hung past its window — each is now a missing ping, and a missing ping is an alert. No mail server, no agent, no log pipeline.
With Drumbeats that is one HTTP request:
0 3 * * * curl -sf https://api.drumbeats.io/v1/ping/<id>/start \
&& /srv/app/nightly.sh \
&& curl -sf https://api.drumbeats.io/v1/ping/<id>/success \
|| curl -sf https://api.drumbeats.io/v1/ping/<id>/failure
-s suppresses the progress meter cron would otherwise mail every run; -f makes an HTTP error exit non-zero. The start ping buys duration tracking and hung-run detection, and the failure branch means you hear about a bad run immediately, not at the next missed window.
Most things called cron jobs in 2026 are not shell scripts, and the ping need not be one either:
import httpx
PING = "https://api.drumbeats.io/v1/ping/<monitor-id>"
def nightly() -> None:
httpx.get(f"{PING}/start", timeout=10)
try:
rows = export_yesterday()
except Exception as exc:
httpx.post(f"{PING}/failure", json={"payload": str(exc)[:2000]}, timeout=10)
raise
httpx.post(f"{PING}/success", json={"payload": f"exported {rows} rows"}, timeout=10)
Pings can include payloads that carry useful detail, telling you whether a job is actually healthy or only looks healthy. For example, this version also closes the exit-zero case, because the success ping fires from the branch that knows the export produced rows.
Failing or missing cron jobs are one of the things that have broad adverse effects on businesses because by nature they go unnoticed for a very long time if they are not monitored. With Drumbeats, you can create monitors for your cron jobs and get alerted when they do not run or fail.
The free tier covers 50 monitors and 200,000 pings a month with unlimited team seats. The scheduled pings guide has the rest of the patterns, and the cron expression generator is free if you need to build the schedule itself.
Frequently asked questions#
Why did my cron job run but not do anything? Cron does not load your shell setup, and only searches /usr/bin:/bin. The command was not found, or a relative path pointed somewhere else.
Why am I not getting cron emails? No mail server installed, or MAILTO unset or empty. Even with one, mail goes to a local mailbox.
How do I know a cron job failed? Cron will not tell you. Add an external monitor that alerts on a missing success signal.
Does cron retry failed jobs? No. No retry, no backoff, no dead-letter behaviour.
How do I stop cron jobs overlapping? flock -n on a lock file, or concurrencyPolicy: Forbid on a Kubernetes CronJob.
What is the difference between cron and anacron? Cron skips jobs entirely if the machine was off; anacron runs them once it is back up, at daily granularity. Persistent=true does the same for systemd timers.
References#
- Vixie, P. crontab(5) — tables for driving cron
- Vixie, P. cron(8) — daemon to execute scheduled commands
- Vixie cron source —
do_command.cfor exit-status handling,pathnames.hfor the_PATH_DEFPATHdefault. - SymfonyCorp. croncape — exit-code-aware cron wrapper
- GNU coreutils manual. timeout: Run a command with a time limit
- util-linux. flock(1) — manage locks from shell scripts
- systemd project. systemd.timer(5) —
Persistent=and timer catch-up - Kubernetes documentation. CronJob —
concurrencyPolicy, scheduling guarantees
Keep following Drumbeats
Prefer a feed reader or want to share this post with your team? The archive stays on permanent Drumbeats URLs and every article is available through standard feeds.
Related reading

Background Job Monitoring vs APM: What Most Teams Actually Need
Teams searching for APM tools to monitor background jobs and async workers are usually shopping in the wrong category. APM traces why code is slow; job monitoring confirms your crons, queues, and workers ran at all. We compare the two honestly — what Datadog, New Relic, and Sentry are genuinely great at, what a heartbeat monitor does instead, and the real June 2026 cost math for a 50-job workload on each path.
