Five fields, left to right
A cron expression is five values separated by spaces: minute, hour, day of month, month, day of week. In that order, always.
Minute runs 0 to 59. Hour runs 0 to 23. Day of month runs 1 to 31. Month runs 1 to 12, or three-letter names. Day of week runs 0 to 7, where both 0 and 7 mean Sunday, or names again.
An asterisk in a field means every value. So `0 9 * * *` reads as minute 0, hour 9, any day, any month, any weekday: nine o'clock every morning.
Most people learn this much and stop. The rest of this piece is the part after that.
Ranges, lists and steps
Three ways to say more than one value, and they combine.
A range is two numbers with a hyphen, inclusive at both ends. `8-11` in the hour field means 8, 9, 10 and 11.
A list is values separated by commas, and the values can themselves be ranges. `1,2,5,9` works. So does `0-4,8-12`.
A step is a slash after a range or an asterisk. `*/15` in the minute field fires at 0, 15, 30 and 45. `0-23/2` in the hour field fires every second hour starting at midnight.
One thing a step does not do, and people assume it does: `*/7` in the day-of-month field is not every seven days. Steps restart at the beginning of each range, so it fires on the 1st, 8th, 15th, 22nd and 29th, then jumps to the 1st of the next month, which may be two or three days later. Cron has no concept of "every N days" across a month boundary. If you need that, you need something other than cron.
The cron expression generator shows the next five real firing times for whatever you type, which is a faster way to catch that kind of thing than reasoning it out.
Mistake one: restricting both day fields
Here is the sentence from the manual page that decides more incidents than any other: if both day fields are restricted, meaning neither is an asterisk, the command runs when *either* matches.
Either. Not both.
So `0 9 1 * 1` does not mean "9am on the first of the month, but only if it is a Monday". It means 9am on the first of every month, and also 9am every Monday. A job written to run twelve times a year runs sixty-odd times instead, and because each individual run looks legitimate, nobody notices until someone asks why the report keeps arriving.
If you genuinely need "first Monday of the month", cron cannot express it in five fields. The usual answer is to fire every Monday and have the script itself check whether the date is 7 or under, then exit if not.
Mistake two: the machine's clock is not your clock
Cron runs in the timezone of the machine, and servers very often run in UTC. Your laptop does not.
A schedule of `0 9 * * *` written at a desk in New York fires at 9am UTC, which is 5am in New York in winter and 4am in summer. The job is not wrong. The expression is not wrong. The assumption about what "9" meant was wrong, and cron has no field for that.
Two things make this worse. Daylight saving means the offset itself moves twice a year, so a job that was tuned to land at a sensible local hour drifts by one hour every spring and autumn. And a job scheduled during the hour that does not exist on the spring-forward night, or exists twice on the autumn night, behaves however that particular cron implementation decided it should, which varies.
The practical rule: schedule in UTC, write down that you did, and convert when you need to know the local time. The time zone converter does the conversion, and a timestamp converter will show you what the machine's own clock thinks the current moment is, which is the number that actually matters.
Mistake three: assuming the syntax is universal
Five fields is standard cron. Plenty of things called cron are not quite that.
Some schedulers add a seconds field at the front, making six. Some add a year at the end. Quartz, used widely in Java systems, has both and also uses `?` in a way standard cron does not. Cloud schedulers frequently have their own dialect, their own timezone handling, and their own idea of what `L` or `W` mean in a day field.
Shortcuts like `@daily`, `@hourly` and `@reboot` are supported by many cron implementations and are not part of the format itself. They are convenient and they are not portable.
Before pasting an expression into a new platform, find that platform's own documentation and check the field count. A six-field expression fed to a five-field parser does not error helpfully. It runs at the wrong time.
Reading one back
Given `30 2 * * 1-5`, work left to right and say it aloud. Minute 30. Hour 2. Any day of month. Any month. Monday to Friday. So: 2:30am on weekdays, in the machine's timezone.
Given `0 */6 * * *`: minute 0, every sixth hour, so midnight, 6am, noon and 6pm.
Given `15 14 1 * *`: 2:15pm on the first of every month. Only one day field is restricted, so no OR surprise here.
If reading it aloud produces a sentence with "and also" in it, you have hit mistake one. If it produces a sentence with "9am" in it and the server is in UTC, you have hit mistake two. Those two account for most of the cron incidents I have ever been asked to look at.
Questions people ask
Minute (0 to 59), hour (0 to 23), day of month (1 to 31), month (1 to 12), and day of week (0 to 7, with both 0 and 7 meaning Sunday), in that order. An asterisk means every value. So `0 9 * * *` is 9:00 every day.
The job runs when either one matches, not when both do. `0 9 1 * 1` fires on the first of every month and also every Monday. To get "first Monday of the month" you need to fire every Monday and have the script check whether the date is 7 or under.
Almost always timezone. Cron runs in the machine's timezone, which on servers is usually UTC, so `0 9 * * *` written from New York fires at 4am or 5am local depending on daylight saving. Schedule in UTC deliberately and convert when you need local time.
No. Steps restart at the beginning of each range, so `*/7` in day of month fires on the 1st, 8th, 15th, 22nd and 29th, then the 1st of the next month. Cron cannot express a true every-N-days schedule across month boundaries.
No. Standard cron is five fields. Quartz and several cloud schedulers add a seconds field, some add a year, and the `?`, `L` and `W` characters mean different things in different dialects. Check the field count in the target platform's own docs before pasting an expression in.

