Cron’s syntax looks small enough to memorise, which is exactly why its edge cases catch people out. All five of the expressions below are valid, and all five have caused a schedule to run at a time nobody intended.
1. The day-of-month / day-of-week OR
0 0 1 * 1
Read naively, this looks like “midnight on the 1st, if it is a Monday”. It is not. When both the day-of-month and day-of-week fields are restricted — that is, neither is * — cron treats them as an OR: the job runs at midnight on the 1st of the month and at midnight every Monday. That is roughly five runs a month instead of one.
This behaviour is inherited from the original Vixie cron and is standard in most implementations, but it is the single most misread rule in the language. If you want “the 1st only when it is a Monday”, you need the day of month in the expression and a guard inside the job:
0 0 1 * *
and then check the weekday in your script before doing the work.
If you want “the first Monday of the month”, cron cannot express it. Use a scheduler with calendar semantics, or compute the date in application code and schedule a one-off run.
2. Steps that silently skip
0 0 */5 * *
*/5 in the day-of-month field means days 1, 6, 11, 16, 21, 26, 31 — not “every fifth day” in the sense of an interval, and not aligned to month boundaries. Because months have different lengths, the gap between the 26th and the next month’s 1st is short. A monthly report written this way will not land on the same relative day each month.
The same pattern in the hours field is safer, because a day is a fixed 24 units. In month and day fields, a step is almost always the wrong tool.
3. Ranges that wrap
0 22 * * 5-1
In some cron implementations this means Friday through Monday (a wrapping range); in others it is rejected or interpreted as an empty set. The safest approach is to write it as an explicit list:
0 22 * * 5,6,0,1
Remember that both 0 and 7 mean Sunday, and that the week starts on Sunday in this field. If your team writes Monday-first schedules, a single off-by-one here moves the job by a day.
4. The hour that does not exist
If a job is scheduled for 02:30 local time in a time zone that springs forward at 02:00, that wall-clock time never occurs on that day. What happens next depends on your scheduler:
- Some implementations skip the run entirely, so a daily job silently misses a day.
- Some run it at the moment the clock jumps, which is 03:00 local.
- Some run it twice, once before and once after the transition.
The same problem appears in reverse in autumn, when 01:30 happens twice and a job can fire twice. Any schedule that matters — billing, report delivery, cleanup — should either run in UTC, or be scheduled far enough from typical transition hours (most zones shift between 00:00 and 03:00 local) that the gap cannot swallow it.
5. Fields with values that can never match
0 0 30 2 *
30 February. This is syntactically valid, semantically impossible, and whether your scheduler rejects it, warns about it, or accepts it and never runs is implementation-specific. Worse, the near-miss versions are harder to see: 0 0 31 4 * (April has 30 days) fails one month a year, which is exactly the kind of thing that is discovered during an April incident.
When you write a schedule with a numeric day of month above 28, check it against every month it could fire in.
A habit that catches all five
Never deploy a cron expression you have not read back as actual dates. Descriptions are where the OR rule hides — “at 00:00 on day-of-month 1 and on Monday” is accurate and easy to skim past.
Generating the next ten real run times in your target time zone makes all five problems visible in a few seconds: the OR rule shows up as extra dates, a DST gap shows up as a missing day, and an impossible date shows up as no results at all. It is the cheapest review step available for a class of bug that is otherwise discovered by its consequences.