Skip to main content
CodeLint.Dev Dev Tools
Developer Tools 9 min read

Cron Expressions Explained: The Complete Guide (Including the DST Bug That Will Eventually Bite You)

Cron has outlived nearly everything else from 1975-era Unix: the same five-field syntax now schedules Kubernetes jobs, GitHub Actions workflows, AWS EventBridge rules, and the backup script on your Raspberry Pi. It is also a syntax nobody fully remembers, with dialect differences across platforms and a daylight-saving-time failure mode that silently skips or double-runs jobs once or twice a year. This guide covers the syntax from scratch, a recipe book of real schedules, platform differences, and the operational pitfalls that separate a cron job that works from one that works reliably for years.

Try the tool
Cron Expression Builder
Build & preview your cron schedule visually →

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 value
  • 1,15 — a list (1st and 15th)
  • 9-17 — a range (9 through 17 inclusive)
  • */15 — a step (every 15 units); 10-50/10 combines 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-509:00 weekdays
0 0 1 * *Midnight on the 1st (monthly)
0 0 1 1 *New Year's midnight (yearly)
0 8-18/2 * * 1-5Every 2 hours, 08–18, weekdays
15 2 * * 002: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 timeZone field (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 EventBridgesix fields (adds a year field), ? required in day-of-month or day-of-week (you cannot restrict both), and extensions like L (last) and W (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.

Frequently Asked Questions

How do I read a cron expression like 0 9 * * 1-5?
Left to right the five fields are minute, hour, day-of-month, month, day-of-week. So 0 9 * * 1-5 means: minute 0 of hour 9, any day of month, any month, days Monday(1) through Friday(5) — i.e. 09:00 every weekday. Special characters: * is "every", lists (1,15), ranges (9-17), and steps (*/15 = every 15). When in doubt, preview the next run times with a builder tool before deploying.
What does */15 mean in cron?
Every 15 units of that field, starting from its minimum. In the minute field, */15 fires at :00, :15, :30, :45 of every hour. Note that steps do not carry across field boundaries: */90 in the minute field does NOT mean every 90 minutes — it means minute 0 of every hour (since only 0 matches a 90-step within 0–59). Intervals longer than an hour that do not divide evenly into a day cannot be expressed in a single standard cron line.
Why does my cron job run on the wrong days when I set both day-of-month and day-of-week?
Standard (POSIX) cron treats restricted day-of-month and day-of-week fields as OR, not AND: 0 0 13 * 5 runs every Friday AND every 13th, not only Friday-the-13th. Most people expect AND, and some schedulers (Quartz, AWS EventBridge, which requires ? in one of the two fields) behave differently. If you need a true conjunction, restrict one field in cron and check the other condition inside your script.
Why does my script work in the terminal but fail in cron?
Cron runs commands in a minimal environment: your shell profile (.bashrc/.zshrc) is not loaded, PATH is nearly empty (often just /usr/bin:/bin), HOME may differ, and there is no TTY. Fixes: use absolute paths for every binary and file, set needed environment variables at the top of the crontab or in a wrapper script, and redirect output to a log file (>> /var/log/job.log 2>&1) so the actual error becomes visible instead of disappearing into unread system mail.
What happens to cron jobs during daylight saving time changes?
In DST-observing timezones, classic cron follows the wall clock: when clocks spring forward past 02:00–03:00, jobs scheduled in the skipped hour simply do not run that night; when clocks fall back, jobs in the repeated hour may run twice. Defenses: schedule in UTC (immune to DST), avoid the 00:00–04:00 local window for daily-exactly-once jobs, make jobs idempotent so double-runs are harmless, and use dead-man's-switch monitoring so a silent skip triggers an alert. Modern schedulers (systemd timers, Kubernetes with timeZone) each handle transitions differently — verify yours.
Are cron expressions the same in GitHub Actions, Kubernetes, and AWS?
The core five-field syntax is shared, but dialects differ materially. GitHub Actions: UTC only, best-effort timing (busy times get delayed/dropped), schedules disabled after 60 days of repo inactivity. Kubernetes CronJob: five fields plus an explicit timeZone field and concurrency policies. AWS EventBridge: six fields (year added), requires ? in day-of-month or day-of-week, supports L and W extensions. Quartz (Java): starts with a seconds field. Never paste an expression across platforms without re-validating field count, timezone, and special-character support.

Ready to try Cron Expression Builder?

Free, private, and runs entirely in your browser — no sign-up, no server, no data sent anywhere.

Open Cron Expression Builder