Cron expressions that fire when you expect
The five fields are simple; the surprises are not. Step values anchored to the field, the day-of-month OR day-of-week rule, time zones, and why "every five hours" is not what */5 means.
· 6 min read
Cron has five fields — minute, hour, day of month, month, day of week — and almost all of the trouble comes from three behaviours that are perfectly logical and not at all what people assume.
Steps are anchored to the field, not to now
*/5 in the hours field does not mean "every five hours". It means
"every hour divisible by five, counting from the start of the field", so it fires
at 00:00, 05:00, 10:00, 15:00 and 20:00 — and then waits four hours,
because 24 does not divide by 5.
The same applies to minutes. */7 * * * * fires at minute 0, 7,
14, 21, 28, 35, 42, 49 and 56, then again at minute 0 — a four-minute gap on the
hour boundary. If you need an even interval, pick a step that divides the field:
2, 3, 4, 5, 6, 10, 12, 15, 20 or 30 for minutes; 2, 3, 4, 6, 8 or 12 for
hours.
Day-of-month and day-of-week are OR, not AND
This one causes real incidents. When both day fields are restricted, most cron implementations fire when either matches:
0 0 1 * 1 # NOT "the 1st, if it is a Monday"
# but "the 1st of every month, AND every Monday"
That is roughly five times more often than intended. Restrict one field or the other, never both, unless you genuinely want the union. The cron explainer warns when an expression has this shape.
Cron does not know your time zone
A crontab uses the system time zone, which is usually UTC in a container and local on a long-lived VM. The same expression therefore runs at different wall times in different places, and a schedule written to avoid business hours can land in the middle of them.
Kubernetes CronJob supports an explicit timeZone
field, which is the right answer when you have it. Otherwise assume UTC, write
the schedule in UTC, and put the intended local time in a comment.
Daylight saving is the sharp edge: a daily job at 02:30 local time will be skipped on the day the clocks go forward and run twice on the day they go back. Schedule anything that must not double-run outside 01:00–03:00 local, or make the job idempotent — which you want anyway.
Days that do not exist
0 0 31 * * fires seven times a year, because only seven months
have a 31st. 0 0 30 2 * never fires at all. For "the last day of
the month", use L if your scheduler supports it, or run daily and
have the job check the date — the second option works everywhere.
Sunday is both 0 and 7
The day-of-week field accepts 0–7, with 0 and 7 both meaning Sunday. Some
schedulers only accept 0–6, so prefer 0 or the name
SUN for portability. Names are clearer anyway:
0 3 * * MON-FRI reads correctly on the first pass, which
0 3 * * 1-5 does not quite.
Five fields, not six
Quartz, Spring's @Scheduled and a few others use a six-field
variant with a leading seconds field. Standard cron and Kubernetes use five and
have minute resolution. Pasting a six-field expression into a five-field
scheduler is either rejected or, worse, silently misread — the seconds value
becomes the minute.
Before you commit a schedule
Read it back in English and look at the next few run times. That takes ten
seconds with the explainer and catches every
mistake above. If you are writing a new schedule, the
generator builds it from a form so the class of
error where 0 3 * * * becomes * 3 * * * — hourly
turning into sixty times an hour — cannot happen.
Tools mentioned in this guide
Cron Expression Generator
DevOps
Build a cron expression from a form and preview the next run times.
Runs in your browser
Cron Expression Explainer
DevOps
Translate a cron expression into plain English and see when it next runs.
Runs in your browser
Unix Timestamp Converter
Developer
Convert a Unix timestamp to a human date and back, in any time zone.
Runs in your browser