Web Toolkit

Cron expressions that run when you expect

Cron is five numbers and a pile of assumptions. Almost every schedule that misfires breaks on one of four of them.

A cron expression is read left to right: minute, hour, day of month, month, day of week. 30 4 * * * is 04:30 every day. That much is easy, and it is where most people stop reading — which is why the same four mistakes keep shipping.

What each field accepts

Every field takes four forms, and they combine: * for all values, a number, a list (1,15), a range (1-5), and a step (*/15). Minutes are 0–59, hours 0–23, day of month 1–31, month 1–12, and day of week 0–6 with Sunday as 0. Most implementations also accept 7 for Sunday and three-letter names (MON, JAN), and most accept those names case-insensitively — but not all, so lowercase mon is a portability risk for no benefit.

The day-of-month / day-of-week trap

This is the big one. 0 9 1 * 1 does not mean "the first Monday of the month". It means the 1st of the month, or any Monday — roughly five times more often than intended.

When both the day-of-month and day-of-week fields are restricted, cron ORs them. Every other pair of fields is ANDed, which is why the rule feels wrong even to people who have read it. The behaviour comes from Vixie cron and is now in POSIX, so it is not a bug you can wait out.

The practical rule: **restrict at most one of the two day fields, and leave the other as *.** If you genuinely need "the first Monday", cron cannot express it. Run it every Monday and exit early in the script when the day of month is above 7.

*/n counts from the start of the range, not from the last run

*/15 on minutes fires at :00, :15, :30 and :45, which is what everyone expects because 15 divides 60. */7 fires at :00, :07, :14, :21, :28, :35, :42, :49 and :56 — and then the hour rolls over and the next run is :00, four minutes later rather than seven.

Steps are applied to the field's own range on each cycle; there is no memory between hours. Any step that does not divide its range evenly produces a short interval at the boundary. If the job cares about even spacing, pick a divisor (5, 10, 15, 20 or 30 for minutes; 2, 3, 4, 6, 8 or 12 for hours) or list the values explicitly.

The timezone is the daemon's, and it moves twice a year

Cron evaluates the expression against the system clock of whatever runs it, which in a container is very often UTC even though the person writing the schedule was thinking in local time. That alone accounts for a large share of "it ran, just not when I said".

Daylight saving makes it worse, because a wall clock that jumps is not a clock the expression can reason about:

  • Spring forward. A job scheduled at 02:30 in a zone that skips 02:00–03:00 does not run at all that day. Some implementations run it once immediately after the jump; others silently skip it. Both behaviours are defensible and neither is what the schedule said.
  • Autumn back. The same 02:30 happens twice, and classic Vixie cron will run the job twice. Anything that is not idempotent — a billing run, an email digest — does real damage here.

Two habits remove the whole category: run schedulers in UTC and convert for humans, and never schedule anything between 01:00 and 03:00 local time. If a job must run at a local hour, put the timezone in the scheduler's own configuration (CRON_TZ on many systems, an explicit field in Kubernetes CronJob and most hosted schedulers) rather than assuming the host.

Five fields, or six?

Standard Unix cron takes five fields. Quartz, Spring's scheduler, AWS EventBridge and several JavaScript libraries take six, with seconds at the front — and some take seven, with an optional year at the end.

The failure mode is quiet: 0 0 12 * * * is noon in a six-field parser and, in a five-field parser that ignores the extra token, midnight. A schedule that looks reasonable and runs twelve hours off is usually this. Check the field count of the specific runner before copying an expression between systems, and be aware that Quartz-family parsers also use ? (no specific value), L (last), W (nearest weekday) and # (nth weekday of the month), all of which standard cron rejects outright.

Shorthands and @reboot

@hourly, @daily, @weekly, @monthly and @yearly are aliases for 0 * * * *, 0 0 * * * and so on — all of them fire on the zero minute, which means every service using @hourly on a fleet hits its dependencies at exactly the same instant. Spreading load is a good enough reason to write 17 * * * * instead.

@reboot is not a schedule at all; it runs once when cron starts. It is not a supervisor, and it will not restart a job that dies.

Cron does not wait for the previous run

If a job scheduled */5 takes seven minutes, cron starts a second copy at minute five. Nothing prevents this, and the usual symptom is a database deadlock or a duplicated export at exactly the load level where the job first became slow.

Wrap anything that could overrun in a lock — flock -n on a lock file is one line on Linux and solves it — or make the job itself idempotent so a double run is harmless.

Verify before you trust

The cheapest check is to read the next several fire times, out loud, in the timezone you care about. Compare the gaps against what you meant: an expression that produces a four-minute gap once an hour, or fires on the 1st and every Monday, shows the mistake immediately once the dates are laid out. Schedules that appear in logs as epoch timestamps are easier to check by converting those timestamps to local time first, and the gap between two runs is easier to reason about as a date difference than as arithmetic in your head.

Tools used in this guide

All guides