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

Esc

Loading the catalog…