Skip to content
100% in your browser. Nothing you paste is uploaded — all processing runs locally. Read more →

Why your cron job didn't fire — six failure modes I keep debugging

6 min read #cron #scheduling #ops

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:

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:

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:

  1. Check the cron log (/var/log/cron, journalctl -u cron, container logs). Did the schedule fire?
  2. Run the command manually as the same user with the same environment (sudo -u cron-user bash -c 'PATH=/usr/bin:/bin /run/job').
  3. Check the next-fire times using the parser on this site — does the schedule actually mean what you think?
  4. Verify the timezone at the top of the crontab and on the system.
  5. Check for overlapping instances with ps aux | grep job-name.
  6. Look for stderr output — if it’s not in a log file, it went to the mail spool that nobody reads.
  7. 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.