Why your cron job didn't fire — six failure modes I keep debugging
The cron expression is correct. The job is in the crontab. It still didn’t run. Or it ran, but at the wrong time. Or it ran twice. Here are the six things to check, in roughly the order they’re likely to be the cause.
1. Wrong timezone
You scheduled 0 9 * * * expecting “9 AM Eastern” but the server is
running in UTC. Job fires at 4 AM your time.
Default crontab timezone behaviour:
- Vixie cron / cron.d: uses the system timezone (
/etc/timezone). - systemd timers: can be specified per-unit with
OnCalendar=Mon *-*-* 09:00:00 America/New_York. - Kubernetes CronJob: uses the kube-controller-manager’s timezone.
As of Kubernetes 1.27, you can set
spec.timeZoneper CronJob. - GitHub Actions: UTC, period.
- AWS EventBridge: cron syntax with explicit UTC; supports rate-based as well.
Fix: set the timezone explicitly. For crontab:
TZ=America/New_York
0 9 * * * /run/job
This sets TZ for the cron daemon’s interpretation of the schedule
(and for the spawned process). Without this line, the schedule is
read as system-local time.
2. The DoM-or-DoW trap fired more than you expected
0 0 15 * 1 # midnight on the 15th + every Monday
You meant “midnight on the 15th, only if Monday.” Cron read it as “OR”. You’re getting fires on every Monday plus the 15th of every month — about 64 a year instead of ~2.
Fix: never restrict both DoM and DoW. Set one to *. If you need
the AND behaviour, do the check inside the job:
[ "$(date +%u)" = "1" ] && /run/the-actual-job
Schedule the wrapper with 0 0 15 * *.
The parser on this site lists next-fire dates so you can verify your intent before the next month surprises you.
3. Job overlapped itself and the second instance failed
Schedule: */5 * * * *. Job takes 10 minutes. Second instance starts
while first is still running. Both fight over the same database lock /
file / port. One or both fail silently.
Symptoms:
- Job appears to run on schedule per cron logs.
- But its output mysteriously goes stale, or the database has gaps, or the metric pipeline shows duplicate rows.
psshows two copies of the script running simultaneously.
Fix: wrap with flock:
*/5 * * * * flock -n /tmp/myjob.lock /run/job
flock -n returns immediately if the lock is held — the next instance
just exits without running. Crucially, flock -n will exit with code 1
if it couldn’t acquire the lock, so check exit codes if your alerting
is set up.
Alternatively, schedule the next run AFTER the current finishes naturally — use a job runner (Sidekiq, Celery, BullMQ) instead of cron.
4. $PATH wasn’t set
A cron environment is not your shell environment. The default
PATH for cron is something minimal like /usr/bin:/bin. Your job
calls python (which is at /usr/local/bin/python), gets command not found, exits.
In the crontab, this happens silently — you don’t see the error unless you redirect stderr to a log.
Fix: set PATH explicitly at the top of the crontab:
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin
0 9 * * * /run/job >/var/log/job.log 2>&1
Or use full paths for every binary in the script.
5. stderr was swallowed
Cron sends a job’s stdout/stderr to the user’s mailbox. On most modern Linux installs, no mail server is running, so the output goes nowhere. The job runs, fails, and you never know.
Fix: explicitly capture both streams:
0 9 * * * /run/job >/var/log/job.log 2>&1
Better: pipe to a log aggregator (logger, journald, your monitoring
service). Best: have your job emit a heartbeat to a service that alerts
on absence (Healthchecks.io, Cronitor, or your monitoring stack’s
absent-metric alerting).
6. The cron daemon isn’t running
Check first if you’re sure the schedule is right:
systemctl status cron # systemd-based distros
service cron status # init-based
ps aux | grep cron # universal
Containers / minimal images (Alpine, distroless, scratch) often don’t
have cron installed at all. Your crontab -e saves to a file, but
nothing reads it. This is silent until you debug it.
Fix: in containerized deployments, use the orchestrator’s scheduling (Kubernetes CronJob, ECS scheduled tasks, etc.) rather than cron inside the container. If you must use cron in a container, verify the daemon is the container’s main process or supervised by something that keeps it running.
A debugging checklist
When a cron job is misbehaving:
- Check the cron log (
/var/log/cron,journalctl -u cron, container logs). Did the schedule fire? - Run the command manually as the same user with the same
environment (
sudo -u cron-user bash -c 'PATH=/usr/bin:/bin /run/job'). - Check the next-fire times using the parser on this site — does the schedule actually mean what you think?
- Verify the timezone at the top of the crontab and on the system.
- Check for overlapping instances with
ps aux | grep job-name. - Look for stderr output — if it’s not in a log file, it went to the mail spool that nobody reads.
- Add a heartbeat so you know the job is at least starting.
A boring suggestion that prevents most of this
For any cron job that’s important, add observability before the job logic itself:
0 9 * * * curl -fsS -m 10 https://hc-ping.com/UUID/start && \
/run/job && \
curl -fsS -m 10 https://hc-ping.com/UUID
You get an alert when the job didn’t fire, didn’t finish, or crashed mid-way. The other 6 issues above all become visible the moment they happen, instead of weeks later when you notice the data is wrong.
Try the parser
The cron parser on this site is the first line of defense — paste your expression and see exactly when it’ll fire next. Catches DoM-or-DoW immediately and shows it in the next-fire list.
Related across the network
- date.tooljo.com/blog/dst-and-date-math — the DST companion to cron’s timezone gotcha. Same root cause.
- epoch.tooljo.com — convert cron’s Unix-second logs back into human dates.
- hash.tooljo.com — for the deduplication case (compute a hash of the input batch so you don’t process the same data twice when a missed-cron’s catchup overlaps).