Why Cron Syntax Still Causes Production Incidents
Cron expressions have powered scheduled jobs since the 1970s, and the syntax hasn't meaningfully changed. That stability is also the problem: five terse fields separated by spaces, with no built-in validation in most systems, means a single typo can silently schedule a job for the wrong time โ or never run it at all. "It'll run at 9am" and "it actually runs at 9am" are two different claims, and only one of them is verified by actually reading the fields correctly.
The Five Fields, In Order
A standard cron expression has exactly five space-separated fields, always in this order:
- Minute (0-59)
- Hour (0-23)
- Day of month (1-31)
- Month (1-12)
- Day of week (0-7, where both 0 and 7 mean Sunday)
Each field independently supports an asterisk (any value), a specific number, a range like 1-5, a step like */15 (every 15 units), or a comma-separated list like 1,15,30. It's easy to misread which field is which โ a common bug is writing * 9 * * 1-5 when you meant 0 9 * * 1-5, which is the difference between "every minute during the 9am hour on weekdays" and "at exactly 9:00am on weekdays".
The Day-of-Month / Day-of-Week Gotcha
The single most confusing rule in cron: when both the day-of-month and day-of-week fields are restricted (neither is *), most cron implementations treat them as an OR, not an AND. So 0 0 1 * 1 means "midnight on the 1st of the month, OR every Monday" โ not "only when the 1st falls on a Monday", which is what many developers assume at first glance. This single behavior has caused more than a few scheduled jobs to run far more often than intended.
Common Schedules Worth Memorizing
* * * * *โ every single minute0 * * * *โ the top of every hour0 0 * * *โ midnight every day*/15 * * * *โ every 15 minutes0 9 * * 1-5โ 9am, Monday through Friday0 0 1 * *โ midnight on the first day of every month
Verify Before You Deploy
- Read each field in order โ minute, hour, day-of-month, month, day-of-week โ out loud if it helps.
- Watch for the day-of-month/day-of-week OR trap whenever both fields are restricted simultaneously.
- Compute the actual next few run times against the real current date, not just a mental description โ this catches off-by-one field mistakes instantly.
- Double-check step values like
*/15โ they always start counting from the field's minimum (0), not from whatever time you happen to deploy at. - Test edge-of-month schedules carefully โ a day-of-month value like 31 simply never fires in months with fewer days, which is valid but easy to forget.
A cron expression that looks right and a cron expression that runs right are only the same thing once you've actually computed its next few executions and confirmed they match your intent.
Testing Your Schedule Before Deploying to Production
Scheduled jobs fail quietly in a way that request-driven code doesn't. If an API endpoint has a bug, someone notices within minutes because a request fails visibly. If a cron job has a bug, it might simply not run โ or run at the wrong time โ for days or weeks before anyone checks the logs and realizes the nightly backup hasn't fired since a deploy three Tuesdays ago.
Before deploying any new or modified cron expression, compute its next several run times against the actual current date and eyeball them against your expectation. This single step catches an enormous share of scheduling bugs: a step value that starts counting from the wrong baseline, a day-of-week field using the wrong numbering convention (some systems use 1-7 for Monday-Sunday instead of 0-6 for Sunday-Saturday), or a month field accidentally left as a day-of-month value after a copy-paste edit.
It's also worth testing across a timezone-sensitive boundary if your infrastructure runs in UTC while your team thinks in local time. A job scheduled for 0 9 * * * on a UTC server runs at 9am UTC, not 9am in whatever timezone the person who wrote it was sitting in โ a mismatch that has caused more than one "why did this run at 2am" incident. Confirming the next run time in the server's actual timezone, not just trusting the expression's face value, closes that gap before it becomes a page at an inconvenient hour.