Five fields, three traps: reading a cron expression

30/07/2026 · Laurent Scheduled tasks
Five fields, three traps: reading a cron expression

Cron syntax is small enough to fit on a business card, which is why every article about it is a table and a list of examples. That is not where the time goes. The time goes into three behaviours that are perfectly documented, entirely unintuitive, and produce jobs that run at the wrong time without anyone noticing for months.

The five fields

┌─ minute        0-59
│ ┌─ hour        0-23
│ │ ┌─ day       1-31
│ │ │ ┌─ month   1-12
│ │ │ │ ┌─ weekday 0-7  (0 and 7 both mean Sunday)
│ │ │ │ │
* * * * *  command

Four operators: * for every value, , for a list, - for a range, and / for a step. That is the entire language.

Trap one: the step applies to what is on its left

*/15 in the minute field means every fifteenth minute of the whole range, so 0, 15, 30, 45. Almost everyone reads it as "every fifteen minutes", and in that field the two happen to coincide.

They stop coinciding as soon as the range is not the full one. 0-30/15 is 0, 15 and 30, then nothing for half an hour. And */40 in the minute field gives 0 and 40, then a twenty-minute gap before the next hour starts over at 0. The step counts from the beginning of its range and it does not wrap, so any step that does not divide sixty produces an uneven schedule.

The same in the hour field is the one that shows up in production. 0 */5 * * * looks like every five hours. It fires at 0, 5, 10, 15 and 20, then waits four hours instead of five. A daily job written this way is off by an hour every day and nobody ever checks.

Trap two: day-of-month and day-of-week are an OR

Every other pair of fields is combined with AND. These two are not, and it is the oldest surprising behaviour in cron.

If both the day-of-month and the day-of-week fields are restricted, the job runs when either matches. So:

0 3 1 * 1    # 3am on the 1st of the month, OR every Monday

This is not an implementation quirk to be checked against your distribution. It is in the specification, and the wording is worth reading once because it explains why the rule feels backwards: POSIX, crontab, read on 3 August 2026, says “if either the month or day of month is specified as an element or list, and the day of week is also specified as an element or list, then any day matching either the month and day of month, or the day of week, shall be matched.” Every field on the line narrows the schedule except these two, which widen it, and only when both are set.

That is not "the first Monday of the month". It is roughly five runs a month. The intended thing cannot be expressed in cron at all; you write a weekly job and have the script check the date itself.

The AND behaviour returns as soon as one of the two is *, which is why most schedules never hit this.

Trap three: the timezone is not yours

A system crontab runs in the machine's timezone, which on most servers and every container image is UTC. Your control panel may display Paris time while the daemon thinks in UTC. Both are internally consistent, so no error appears, and a job scheduled for eight in the morning runs at nine or at ten depending on the season.

Daylight saving makes it worse in a way that is worth knowing before it happens rather than after. On a machine that is in a shifting timezone, the spring change deletes an hour: a job at 02:30 simply does not run that day. The autumn change repeats an hour, and depending on the implementation the job runs twice. If the job sends invoices, running it twice is not a rounding error.

The fix is to schedule in UTC and convert in your head, or to set the timezone explicitly where the daemon supports it. Not to hope.

The fourth trap, which is not syntax

Everything above produces a job that runs at the wrong time. The failure that costs more is the job that does not run at all, and it has no symptom.

A successful cron job is silent. A cron job that was never scheduled is silent. A cron job whose interpreter moved is silent, because the error went to an email address nobody configured. A cron job on a container that was rebuilt without its crontab is silent. Four different states, one observable behaviour: nothing.

This is qualitatively different from a website going down. A site that stops answering produces an error for every visitor, immediately. A backup that stops running produces nothing at all, and you find out when you need the backup.

The standard advice is to redirect output to a log. That catches the job that runs and fails. It cannot catch the job that never started, because a job that never started writes nothing to a log, and an empty log looks exactly like a quiet week.

What catches it is inverting the test: the job announces itself when it finishes, and the absence of that announcement is the alert. The only number to choose is how long silence is allowed to last, and it has to be longer than the job's own worst run or the alert fires every time the backup is slow. In our engine the floor is 30 seconds and the value is per job, in src/Heartbeat.php, read on 3 August 2026.

The same reasoning applies one level up, to the scheduler that runs the checks. Ours reports itself inactive after 900 seconds without a pass, because a monitoring tool whose own cron stopped is the one failure that would let every other check pass silently. Fifteen minutes rather than one: a single missed minute is a busy server, a quarter of an hour is a stopped scheduler.

The only thing that detects absence is expecting presence: the job announces itself when it finishes, and something else notices when the announcement does not arrive. Which is a whole subject on its own, and it has its own article coming, because "we assumed it was running" is the most expensive sentence in this trade.

A short checklist before you commit an expression

Read the step operators out loud with the word "of": every fifteenth minute of the hour, every fifth hour of the day. If the sentence sounds wrong, the schedule is wrong.

If both day fields are restricted, rewrite it. You almost certainly wanted an AND.

Check what timezone the daemon believes it is in, on the actual machine, not in the panel.

And write down what the job's absence would look like. If the answer is "nothing", you do not have a scheduled job, you have a hope.

None of this is about status codes, but the underlying failure is the same one as a code read without its context: the absence of an error is not evidence of success, and every monitoring decision follows from taking that seriously. That argument, made in full and about the one code nobody questions, is why a 200 proves so little.

Back to contents

Share this post.
Stay up-to-date

Subscribe to our newsletter

Don't miss this

You might also like