The Five Fields, Once and For All
A standard cron expression is five space-separated fields, read left to right as: minute, hour, day of month, month, day of week.
┌───────────── minute (0–59)
│ ┌─────────── hour (0–23)
│ │ ┌───────── day of month (1–31)
│ │ │ ┌─────── month (1–12 or JAN–DEC)
│ │ │ │ ┌───── day of week (0–6, SUN=0; 7 also = SUN in most dialects)
│ │ │ │ │
* * * * * command
Each field accepts:
*— every value ("every minute", "every hour"…)5— an exact value1,15— a list (1st and 15th)9-17— a range (9 through 17 inclusive)*/15— a step (every 15 units);10-50/10combines range and step
The classic gotcha is the day-of-month / day-of-week interaction: when both are restricted, standard cron runs the job when either matches (OR, not AND). So 0 0 13 * 5 fires every Friday and every 13th — not only Friday the 13th. Most people expect AND; POSIX cron gives you OR. (Quartz and some modern schedulers handle this differently — one more reason to preview a schedule before trusting it.)
Also remember what cron cannot express: "every 90 minutes" (steps do not carry across field boundaries — */90 in minutes means minute 0 of every hour), "last weekday of the month" (needs non-standard extensions), or "second Tuesday" (needs 1-7-style range tricks plus a day-of-week, or scheduler extensions).
The Recipe Book: Schedules You Will Actually Need
| Expression | Meaning |
|---|---|
| */5 * * * * | Every 5 minutes |
| 0 * * * * | Top of every hour |
| 30 3 * * * | 03:30 daily (classic backup slot) |
| 0 9 * * 1-5 | 09:00 weekdays |
| 0 0 1 * * | Midnight on the 1st (monthly) |
| 0 0 1 1 * | New Year's midnight (yearly) |
| 0 8-18/2 * * 1-5 | Every 2 hours, 08–18, weekdays |
| 15 2 * * 0 | 02:15 Sundays (weekly maintenance) |
| 0 0 28-31 * * | "Last days" of month — pair with a script check for true last-day |
Recipes come with operational advice baked in:
- Avoid minute 0 of hour 0. Half the internet schedules at midnight; shared infrastructure (CI runners, APIs you call) is measurably slower and flakier then. Pick an odd minute like 3:47.
- "Last day of month" needs a trick in standard cron: schedule
0 0 28-31 * *and have the script exit unless tomorrow is the 1st ([ "$(date -d tomorrow +%d)" = "01" ]). - Nicknames (
@daily,@hourly,@weekly,@reboot) work in most crontabs — readable, but not portable to every platform.
Same Syntax, Different Dialects: Platform Differences
The five fields travel everywhere, but the details do not:
- Linux crontab — the baseline. Runs in a minimal environment: your shell profile is not loaded, PATH is nearly empty, and this — not the schedule — causes most "works in terminal, fails in cron" mysteries. Use absolute paths, set env vars in the crontab, redirect output to a log.
- Kubernetes CronJob — five fields plus a
timeZonefield (stable since 1.27 — set it explicitly). Adds distributed-systems semantics:concurrencyPolicy(Allow/Forbid/Replace overlapping runs),startingDeadlineSeconds, and a critical default — missed runs during controller downtime are counted, and 100+ misses stop the job permanently unless a deadline is set. - GitHub Actions — five fields, UTC only, no timezone option. High-traffic times (top of the hour) are delayed or dropped under load — schedules are best-effort, not guaranteed; runs on default branch only, and scheduled workflows in repos with 60 days of inactivity get disabled.
- AWS EventBridge — six fields (adds a year field),
?required in day-of-month or day-of-week (you cannot restrict both), and extensions likeL(last) andW(nearest weekday). Quartz (Java) similarly uses 6–7 fields starting with seconds — a Quartz expression pasted into crontab is silently wrong. - Cloudflare Workers, Vercel, and friends — five fields, UTC, with per-platform limits on schedule count and granularity.
The rule: never paste a cron expression between platforms without re-validating it — field counts, timezone behavior, and special characters all shift under the same-looking syntax.
The DST Bug: How Timezones Silently Skip or Double-Run Jobs
Here is the failure mode that finds every team eventually. Cron fires when the local wall clock matches the expression — and wall clocks in DST-observing timezones do impossible things twice a year:
- Spring forward: the missing hour. When clocks jump from 02:00 to 03:00, a job scheduled at 02:30 has no 02:30 that night — classic cron simply skips it. Your nightly billing run silently does not happen once a year.
- Fall back: the doubled hour. When 02:00 occurs twice, naive schedulers run the 01:30 job twice. If the job is not idempotent — sends invoices, charges cards — customers get two.
- Modern cron implementations (systemd timers, recent cronie, Kubernetes with timeZone set) mitigate parts of this, each differently. "What does my scheduler do at DST transitions" is a question with a per-platform answer, and most people discover theirs in production.
The defensive playbook:
- Schedule critical jobs in UTC (or run servers in UTC) — UTC has no DST, so the entire class of bugs vanishes. Use local-time scheduling only when the business genuinely requires wall-clock alignment (e.g. "9am for our Berlin users").
- Avoid 00:00–03:59 local time for anything that must run exactly once daily in a DST zone — that window is where clocks misbehave.
- Make jobs idempotent anyway — a job safe to run twice converts the doubled-hour case from an incident into a log line. (The same property saves you when an operator reruns a failed job manually.)
- Monitor for absence, not just failure. A skipped job produces no error — it produces silence. Dead-man's-switch monitoring (the job pings a URL on success; the monitor alerts when the ping does not arrive) catches skips, hangs, and disabled schedules alike. This one habit catches more cron incidents than any other.
Production Cron Hygiene: The Checklist
The difference between a cron job and reliable automation, condensed:
- ✅ Validate the expression and preview the next 5–10 run times before deploying — misread fields (swapping minute/hour is the classic) survive code review with ease.
- ✅ Absolute paths and explicit environment. Cron does not load your profile; set PATH and required env vars in the crontab or wrapper script.
- ✅ Log output somewhere. Default cron mails output to a mailbox nobody reads. Redirect to a log file or logging system:
>> /var/log/myjob.log 2>&1. - ✅ Lock against overlap. A job slower than its interval piles up concurrent copies. Use
flock -n /tmp/myjob.lock(Linux) or the platform's concurrency policy (Kubernetes Forbid/Replace). - ✅ Idempotency + retries. Design every job to be safe to run twice and to make progress after a crash. Retry transient failures inside the job with backoff rather than waiting for tomorrow's run.
- ✅ Dead-man's-switch monitoring for anything that matters — alert on missing success signals, not just on errors.
- ✅ Document the intent next to the expression: a comment saying "daily at 03:47 UTC — invoice batch; safe to rerun" turns 3 a.m. debugging from archaeology into reading.
- ✅ Prefer UTC; when local time is required, write the timezone explicitly (Kubernetes timeZone, CRON_TZ where supported) instead of relying on server defaults someone will change.
Cron's five fields are a fifty-year-old interface that will outlive most of the platforms in this article. Learn them once, respect the dialects and the DST demon, and your scheduled jobs become the most boring — that is, the best — part of your infrastructure.