A cron expression that is wrong rarely errors. It just runs at some other time, or never, and you find out days later when a report is missing. Half of these failures are the expression itself and half are the environment the job runs in, which is far more stripped down than the shell you tested in. The sections below go through the specific traps in the order they usually bite, starting with the one that explains most pasted expressions behaving strangely. Every fix here can be verified in under a minute without waiting for the next scheduled run.
Most "my expression is wrong" reports are a schedule written for one dialect and pasted into another. Standard Unix crontab takes five fields: minute, hour, day-of-month, month, day-of-week. Quartz and Spring's @Scheduled(cron=…) put seconds first, so they take six, and Quartz allows an optional seventh for year. AWS EventBridge also takes six, but its sixth is year, not seconds, and it requires ? in either day-of-month or day-of-week because it refuses to let you restrict both.
So 0 3 * * * is 3am daily in crontab and a parse error in Spring, where the same schedule is 0 0 3 * * *. If you paste a five-field expression into Spring it may still parse and run every minute at second zero of the hour you meant to be a minute. Check the field count first, every time.
This is in the POSIX spec and it surprises nearly everyone. When both day fields are restricted — neither is * — cron fires if either matches. 0 9 13 * 5 does not mean "9am on Friday the 13th". It means 9am on the 13th of every month and 9am every Friday. If you genuinely need the intersection, schedule the looser one and test the other condition in the script:
0 9 13 * * [ "$(date +\%u)" = "5" ] && /opt/app/bin/report.sh
*/5 in the minute field is every five minutes, which is what people expect. 0 */5 * * * is every five hours — 00:00, 05:00, 10:00, 15:00, 20:00 — and then a four-hour gap across midnight, because the step restarts at hour 0 each day. Any step that does not divide evenly into the field's range produces that uneven wrap. To run on a schedule that respects the day boundary, list the hours: 0 0,6,12,18 * * *. Ranged steps like 0 9-17/2 * * 1-5 work and are clearer than the alternative.
Vixie cron runs on local time. On a spring-forward day the local clock jumps from 01:59 to 03:00, so a job at 02:30 has no wall-clock moment to run in and is skipped. On the autumn transition 01:00–02:00 happens twice and a job in that window can run twice. Some cron implementations compensate for jobs scheduled inside the skipped hour and none of them do it identically, which is worse than a clear rule.
Two reliable fixes. Set the job's timezone to UTC — either the whole host, or a CRON_TZ=UTC line above the entry in cron implementations that honour it — and accept that a "9am" report drifts by an hour twice a year. Or schedule outside the transition window entirely: nothing between 01:00 and 03:00 local. Also make the job idempotent, because a double run should be boring rather than a duplicate invoice.
cron gives the job a nearly empty environment. There is no .bashrc, no nvm, no pyenv shim, and PATH is often just /usr/bin:/bin. Use absolute paths for every binary and every file the script touches. The other silent killers:
% in the command means "newline" to crontab, so date +%F truncates the line. Write date +\%F.>> /var/log/myjob.log 2>&1 and you have a debugging trail./etc/cron.d need a user field between the schedule and the command; a personal crontab -e entry must not have one.A five-minute job that occasionally takes seven minutes will eventually have two copies fighting over the same database rows. Wrap it: flock -n /var/lock/myjob.lock /opt/app/run.sh exits immediately if a run is already holding the lock. To confirm a schedule without waiting an hour, run the expression through croniter in Python and print the next ten fire times against a fixed start date — that catches the OR rule and the step-wrap immediately. For systemd timers, systemd-analyze calendar "Mon *-*-* 09:00:00" prints the normalised form and the next elapse. Finish with crontab -l to confirm the file the daemon actually loaded matches what you edited.