Skip to content
AZ Tools

How Cron Schedules Actually Work

A cron expression is five small numbers that decide when your job runs — and a surprising number of production incidents come from misreading them. This guide covers the syntax, the one rule that behaves the opposite of how it reads, the time-zone traps, and the habits that keep a schedule reliable once it is running unattended.

The five fields

Standard cron takes five fields, in order: minute (0–59), hour (0–23), day of month (1–31), month (1–12), and day of week (0–6, where 0 is Sunday). Each field accepts a single value, a list (`1,15`), a range (`9-17`), a step (`*/15`), or `*` for "every". So `0 9 * * 1-5` means nine in the morning, Monday through Friday.

Steps apply to the range in front of them, which is what makes `*/15` in the minute field mean "0, 15, 30, 45" rather than "every fifteen minutes starting whenever the job was created". A schedule fires when the current time matches the pattern — cron does not track intervals, it watches the clock.

Day of month and day of week are OR, not AND

This is the rule that surprises everyone. When both the day-of-month and day-of-week fields are restricted, cron runs the job when *either* matches — not when both do. So `0 0 13 * 5` is not "Friday the 13th": it runs on the 13th of every month, and also on every Friday.

The behaviour only applies when both fields are restricted. If one of them is `*`, the other simply decides. To get a true intersection you need a guard inside the job itself — check the date at the top of the script and exit early — because the expression cannot express it.

Which clock is it running on?

A cron expression carries no time zone. The schedule is interpreted in whatever zone the runner uses: the machine's local zone for classic crond, UTC for most managed schedulers and CI systems. The same five fields therefore mean different real-world moments depending on where they are deployed, which is how a "midnight" report ends up landing mid-afternoon.

If the runner uses a zone that observes daylight saving, twice a year the schedule misbehaves in one of two ways. On the spring-forward night, a job scheduled inside the skipped hour never runs at all. On the autumn night, the repeated hour can run the job twice. Scheduling in UTC avoids both, at the cost of the local wall-clock time drifting by an hour across the year — pick whichever failure you can live with, deliberately.

Extensions, nicknames, and the sixth field

Plenty of schedulers extend the classic syntax, and not compatibly. Quartz-style expressions add a leading seconds field, so a six-field expression means something entirely different from a five-field one and a copied schedule can fire sixty times too often. Some flavours add `L` (last), `W` (nearest weekday) and `#` (nth weekday of the month); some accept `?` in place of `*` in the day fields.

Most implementations also accept nicknames: `@hourly`, `@daily`, `@weekly`, `@monthly`, `@yearly` and sometimes `@reboot`. They are clearer than the numeric equivalents where they fit, but they hide the exact minute — `@daily` is midnight, which is also when every other `@daily` job on the machine starts.

Designing schedules that survive

Assume a run will be missed, repeated, or overlapped, and design the job so none of those corrupt anything. Make the work idempotent so a double run is harmless, take a lock so a slow run does not overlap the next one, and record what was processed so a skipped run can catch up rather than leaving a silent gap.

Spread schedules out. Everything set to `0 0 * * *` starts at once, and on a shared host that thundering herd is self-inflicted. Pick an odd minute, stagger related jobs, and add a small random delay at the start of anything that hits a shared API. Finally, alert on the job *not* having run — a schedule that silently stops firing looks exactly like a schedule with nothing to do.

  • `*/15 * * * *` — every quarter hour, on the quarter.
  • `0 9 * * 1-5` — 09:00 on weekdays, in the runner's time zone.
  • `0 0 13 * 5` — the 13th *or* any Friday, not Friday the 13th.
  • `0 3 1 * *` — 03:00 on the first of the month.

Related tools