DevOps
Cron Expressions Explained: From */5 to the OR-Rule Nobody Expects
· 3 min read
Cron has outlived nearly everything else from 1975 Unix because its job never went away: run this thing on that schedule. The five-field expression syntax is compact enough to fit in a YAML value and expressive enough to cover most real schedules — and just ambiguous enough that misreading it causes silent production incidents: reports that never send, cleanups that run 60 times an hour, billing jobs that fire twice.
This guide builds the mental model that makes cron expressions readable at a glance, then covers the traps that survive code review.
The five fields and their grammar
An expression is five space-separated fields: minute (0–59), hour (0–23), day of month (1–31), month (1–12), day of week (0–7, where 0 and 7 both mean Sunday). Each field takes a value, a list (1,15), a range (9-17), a step (*/5, 10-40/10), or * meaning 'every'.
Read expressions right to left, the way you would say them: 0 9 * * 1-5 → 'Monday through Friday, at hour 9, minute 0'. That habit alone catches the most common misread — * in the minute field. * 9 * * * is not 'at 9:00' but 'every minute from 9:00 to 9:59', an error that turns one email into sixty.
┌───────── minute (0-59) │ ┌─────── hour (0-23) │ │ ┌───── day of month (1-31) │ │ │ ┌─── month (1-12) │ │ │ │ ┌─ day of week (0-7) │ │ │ │ │ 0 9 * * 1-5 → weekdays at 09:00
The OR-rule: cron's most surprising behavior
When both day-of-month and day-of-week are restricted, POSIX cron fires when either matches. 0 9 13 * 5 does not mean 'Friday the 13th' — it means 'every 13th of the month AND every Friday', separately. A job meant to run monthly suddenly runs four to five times a week.
The rule exists for historical compatibility and cannot be turned off in standard cron. When you need true AND semantics, restrict one field and check the other in the job itself: schedule 0 9 13 * * and exit early unless the weekday is Friday. Quartz solved this differently with an explicit ? placeholder — one of several reasons Quartz expressions do not paste into crontab.
Time zones and the night DST eats your job
A cron expression has no timezone of its own; it fires by the scheduler's clock. The same 0 3 * * * lands at different absolute times on a UTC server, a Bangkok server, and a developer laptop — and the classic incident is a schedule written with local time in mind running on a UTC host, off by exactly the offset.
Daylight saving adds a sharper trap: on spring-forward night, 02:00–02:59 local time simply does not happen, and jobs scheduled there are skipped; on fall-back night, that hour happens twice and naive schedulers run twice. Two defenses: run schedulers in UTC (and declare it — Kubernetes CronJob supports spec.timeZone), or keep critical schedules out of the 01:00–03:00 local window.
Dialects: crontab is not Kubernetes is not Quartz
The five-field core is portable; everything around it is not. Vixie cron adds shortcuts (@daily, @reboot). Kubernetes CronJob uses standard five fields but adds concurrency policy and missed-run deadlines as API fields. Quartz uses six or seven fields (seconds first, optional year) plus ? L W # extensions — its strings are a different language. AWS EventBridge requires ? in one of the day fields and supports a year field. GitHub Actions accepts plain five-field syntax but evaluates strictly in UTC and does not guarantee minute-level punctuality.
The practical rule: when moving a schedule between systems, re-derive it from the human intention ('weekdays at 9') rather than copying the string. It takes ten seconds with a generator and removes a whole class of silent mistranslation.
Schedules that behave in production
- Stagger fleet-wide jobs: a thousand servers running 0 0 * * * hammer shared infrastructure at midnight. Hash the hostname into the minute field (7 0 * * *, 23 0 * * *, …).
- Prevent overlap explicitly: cron happily starts run #2 while #1 is still going. Use flock in crontab or concurrencyPolicy: Forbid in Kubernetes.
- Plan for missed runs: standard cron has no catch-up. If the box was down at the scheduled time, the run never happens — anacron, systemd timers with Persistent=true, or a startingDeadlineSeconds cover the gap.
- Log the effective time: one date line at job start turns every future timezone debate into a two-minute lookup.
- Verify before shipping: check the next few fire times of any non-trivial expression — the ten-second habit that catches OR-rule surprises, impossible dates (0 0 31 2 *), and swapped fields.