That cron expression was written for another cron
0 3 * * * means three in the morning on every cron ever written. That is about where the agreement ends.
The word "cron" now covers at least seven unrelated implementations that share a look and disagree on the details. An expression copied from an answer written for a Java scheduler, pasted into a crontab on a shared host, does not throw an error. It parses as something else, or the whole file is rejected, and either way the first symptom arrives weeks later.
What the dialects disagree about
| Runtime | Fields | Weekday numbering | Extras it adds |
|---|---|---|---|
| Vixie cron, cronie (most Linux distributions) | 5 | 0 to 7, both 0 and 7 are Sunday | Names, @daily and friends, @reboot |
| BSD and macOS cron | 5 | 0 to 7, same convention | Largely the same, minus some cronie variables |
| BusyBox crond (Alpine containers) | 5 | 0 to 6 | A reduced set. Treat anything beyond the four operators as unavailable until tested. |
Quartz, Spring @Scheduled | 6 or 7 | 1 is Sunday | Seconds first, optional year last, ?, L, W, # |
| AWS EventBridge | 6 | 1 is Sunday | Year field, ? required in one of the two day fields |
| Kubernetes CronJob | 5 | 0 to 6 | Nothing. It is the plain form, and @reboot has no meaning in a cluster. |
| Jenkins | 5 | 0 to 7 | H, which spreads jobs deterministically instead of stacking them |
| systemd timers | Not cron at all | Day names | OnCalendar, a different grammar with its own tester |
Two rows of that table produce most of the real incidents.
The seconds field, which turns three in the morning into three in the afternoon
Quartz expressions lead with seconds. 0 0 3 * * ? in Quartz is three in the morning. The same string in a Unix crontab has six tokens before the command, so the sixth is read as the start of the command in a user crontab, or as a user name in /etc/cron.d.
The nastier direction is the five-field expression pasted into a Quartz configuration. 0 3 * * * becomes second zero, minute three, hour of every value: a job that was meant to run once a day now runs once an hour, at three minutes past. Nothing errors. The application does its work twenty-four times more often than intended, and if that work sends email or charges a card, you will hear about it from the client rather than from a log.
The tell is ?. If an expression contains a question mark, it is not a Unix crontab expression and never was. Unix cron has no such operator, and its presence means the author was writing for Quartz or for EventBridge, both of which refuse to let you constrain day-of-month and day-of-week at the same time.
The L, W and # operators, which exist to express the thing plain cron cannot
Plain cron combines day-of-month and day-of-week with OR, which is why "the second Tuesday of the month" is not expressible in it. Quartz added operators specifically for this: L for last, W for nearest weekday, # for the nth occurrence. 0 0 3 ? * 3#2 is genuinely the second Tuesday.
None of them exist in Vixie cron. Written into a crontab, 3#2 is a parse error, and the consequence depends on where the line lives: crontab -e will usually refuse to install the file, while a file dropped into /etc/cron.d can be rejected wholesale, taking valid entries alongside it.
When you need "last day of the month" on plain cron, the answer is not an expression. Run daily and let the script decide:
0 3 * * * [ "$(date -d tomorrow +\%d)" = "01" ] && /usr/local/bin/monthly.sh
Note the escaped percent signs. Inside a crontab they are newlines until you escape them, which is the first thing that breaks in the command portion of the line.
Weekday numbering, and why the off-by-one hides for a week
Vixie cron accepts 0 through 7, with 0 and 7 both meaning Sunday. Quartz and EventBridge start at 1 for Sunday. So 1 means Monday on your server and Sunday in a Lambda schedule, and every subsequent number is off by one.
This is a difficult class of bug because a weekly job that runs on the wrong day still runs, still succeeds, and still writes a log line. The only symptom is a report that arrives on Sunday when the client expected Monday, which they will mention in passing three weeks later.
Names sidestep it. MON means Monday in every implementation that accepts names, and it reads correctly out loud. Ranges over names are less consistently supported than single names, so MON-FRI is worth testing on the target rather than assuming.
The special strings, and the directory that is not cron
@daily, @hourly, @weekly and the rest are conveniences of the mainstream Unix implementations. They are the first thing missing from cut-down builds, and they are meaningless in schedulers that use a different grammar. @reboot in particular has no equivalent anywhere outside a long-lived machine, and in a container orchestrator the concept it names does not exist.
Then there is /etc/cron.daily, which looks like a scheduling mechanism and is a directory of scripts executed by run-parts. Two consequences that reliably surprise people:
The file name is a filter. run-parts ignores files whose names contain characters outside its accepted set, and a dot is outside it on the common implementations. A script saved as backup.sh in /etc/cron.daily is not run. It sits there, executable, looking correct, doing nothing. Drop the extension.
"Daily" is a window, not a time. On systems where anacron is involved, the daily set runs at some point after the machine is awake and within a delay range, precisely so that every server in a fleet does not start its maintenance at the same instant. That is usually what you want. It is not what you want if the job has to finish before a client's business day.
Testing the expression against the runtime that will execute it
Reading the documentation is slower and less convincing than asking the machine.
For systemd, the answer is built in and it prints real dates:
systemd-analyze calendar --iterations=5 'Mon *-*-* 03:00:00'
For plain cron, there is no equivalent shipped, and the closest honest substitute is a job that only records that it fired. Install the schedule with a command that appends a timestamp to a file, leave it for a cycle, read the file. It costs a day and it answers the question about the exact daemon, the exact timezone and the exact account, which no parser on the internet can do.
For anything expressed in an application framework, test through the framework's own listing command rather than reasoning about the string, because the framework may add its own layer of interpretation on top of the expression it accepts.
The rule that removes most of the problem
Keep one schedule per machine and one dispatcher behind it.
A single crontab entry that runs every minute and hands control to an application-level scheduler moves every expression you own into one place, expressed in one dialect, versioned in the repository, testable without root. The portability question then only has to be answered once, for one line, which is the line that says the dispatcher runs.
What that line cannot tell you is whether it is still there. A dispatcher removed by an account migration, or absent from a rebuilt image, takes every job with it and produces exactly the same output as a quiet night: nothing at all.
The portable answer, if you would rather not depend on the format at all
Everything above is a difference between implementations, and every one of those differences is a thing that can be right on your machine and wrong on the client's shared host. There is a way to stop caring, and it is not a better expression.
Declare what the job is supposed to do rather than when: an expected interval and a tolerance, with a ping at the end of the command. That is all our engine stores, and you can read the whole of it in src/Heartbeat.php. The expression stays whatever the host accepts, the schedule becomes something you can verify from outside, and a job that silently stopped firing on one host out of forty becomes visible on the day it stops.