Skip to content
Guide · 12 min read

Cron 101 — read this before you write your next schedule

The actual reference. Five fields, special characters, day-of-week numbering, the things that bite, and when not to use cron at all.

Verified by maintainer·Last updated

Cron is one of those tools that has been around so long nobody writes about it carefully anymore. Most tutorials are 80% copy-paste examples and 20% caveats nobody reads. This is the article I wish I had handed to every junior who asked me "what does the asterisk mean" in a code review.

What cron actually is

Two things share the name. The cron daemon is a Unix process that wakes up every minute, reads a table of schedules, and runs the commands whose time has come. The cron expression is the schedule line: a string of fields that says "at these minutes, on these hours, on these days, run that command". When people say "a cron job", they usually mean the schedule + the command together.

Modern schedulers — Kubernetes CronJob, AWS EventBridge, GitHub Actions, Vercel, Quartz — re-use the expression syntax (with variations) without re-using the daemon. Almost nobody who writes 0 9 * * 1-5 in 2026 is talking to vixie cron directly.

The five fields

┌──────── minute        (0 - 59)
│ ┌────── hour          (0 - 23)
│ │ ┌──── day of month  (1 - 31)
│ │ │ ┌── month          (1 - 12  or  JAN-DEC)
│ │ │ │ ┌ day of week    (0 - 7   or  SUN-SAT, 0 and 7 both = SUN)
│ │ │ │ │
* * * * *  /path/to/command

Whitespace separates the fields. The order is fixed and there is no way to skip a field. A field is one of:

  • A single value: 5 means "5".
  • A range: 1-5 means "1, 2, 3, 4, 5".
  • A list: 1,3,5 means exactly those three values.
  • A step: */15 means "every 15". 0-30/5 means "0, 5, 10, 15, 20, 25, 30".
  • A wildcard: * means every valid value for the field.

You can combine these. 0,30 9-17 * * 1-5 reads as "at minute 0 and 30, from hour 9 through 17, every day, every month, Monday through Friday". That is the canonical "every half hour during business hours, weekdays only" pattern.

The fields, one at a time

Minute (0–59)

The simplest field. 0 is the top of the hour, 30 is half past, */5 is every five minutes. Be careful with */7: it fires at minute 0, 7, 14, 21, 28, 35, 42, 49, 56 — then there is an 11-minute gap at the top of the next hour because the cycle restarts. If you want truly "every 7 minutes", cron is the wrong tool.

Hour (0–23)

24-hour clock, midnight is 0, 11 PM is 23. Cron never understands AM/PM. 9-17 is the business-hours range; */2 fires on even hours (0, 2, 4...).

Day of month (1–31)

A common trap. 0 0 31 * * only fires in months that have a 31st — so the job runs in January, March, May, July, August, October, and December. Eight months a year. For "last day of the month" you need L (Quartz / AWS only) or a shell hack like [ $(date -d tomorrow +\%d) = 01 ] || exit 0.

Month (1–12)

Numeric or three-letter alias: JAN through DEC. 1,4,7,10 is the quarterly pattern. Most platforms accept aliases except Vercel, which rejects alphabetic forms on principle.

Day of week — the field that bites

This is where porting between platforms goes wrong. Three numbering conventions exist:

  • Unix cron: 0 or 7 = SUN, 1 = MON, ..., 6 = SAT.
  • AWS EventBridge / Quartz: 1 = SUN, 2 = MON, ..., 7 = SAT.
  • Spring 5.3+: 1 = MON, ..., 7 = SUN. Yes, different from both of the above.

When in doubt, use the alphabetic form (MON-FRI). It is unambiguous on every platform except Vercel. If you need numeric on Vercel, know which one you are using — since Vercel uses Unix conventions, weekdays are 1-5.

Special characters worth knowing

  • * — every value. The default for "I don't care about this field".
  • , — list separator. 1,15,30 fires three times per period.
  • - — range. 1-5 fires at 1, 2, 3, 4, 5.
  • / — step. */15 fires every 15 from 0. 5-30/5 fires at 5, 10, 15, 20, 25, 30.
  • ? — Quartz and AWS only. Means "no specific value" in day-of-month or day-of-week. Required to disambiguate which of the two fields wins.
  • L — Quartz and AWS only. "Last": L in day-of-month means "last day of month". 6L in day-of-week means "last Friday of month".
  • W — Quartz only. "Weekday nearest to". 15W means "the weekday closest to the 15th".
  • # — Quartz only. MON#1 means "the first Monday of the month".

Special strings (the aliases)

GNU cron and most modern crons accept shortcuts:

  • @yearly / @annually — same as 0 0 1 1 *.
  • @monthly0 0 1 * *.
  • @weekly0 0 * * 0.
  • @daily / @midnight0 0 * * *.
  • @hourly0 * * * *.
  • @reboot — runs once when cron starts. Not supported on cloud schedulers; unreliable in containers; avoid.

The five most common mistakes

1. Off-by-one in day-of-week

Copy 0 9 * * 1-5 from a Linux machine to AWS EventBridge and you have just scheduled a job for Sunday-through-Thursday at 09:00. Always check the numbering after a port. cronpreview validates this at edit time.

2. Day-of-month AND day-of-week both set

On Unix cron, 0 0 15 * 1 fires on the 15th of any month OR any Monday, whichever comes first. That is almost never what people want. They usually want "on Monday the 15th" (an AND, not an OR), which cron cannot express natively. AWS and Quartz force you to ? one of them to avoid this confusion.

3. */N on a non-divisor

*/45 in the minute field does not fire every 45 minutes. It fires at minute 0 and minute 45 of every hour — then the cycle restarts and minute 30 is skipped. If you want exactly 45-minute intervals, use a queue, not cron.

4. Assuming the wrong timezone

0 9 * * * on a Linux server uses the system clock (usually UTC in containers, usually local on bare metal). On GitHub Actions and on Vercel it is always UTC. On AWS EventBridge classic Rules it is always UTC; on EventBridge Scheduler you set it explicitly. On Kubernetes you set .spec.timeZone (1.27+). Always know which clock your scheduler reads.

5. Frequencies finer than the platform allows

GitHub Actions silently rounds anything under 5 minutes. Vercel Hobby limits you to one run per day total. AWS EventBridge bills per invocation; * * * * * in production for a year is 525,600 invocations. None of these failures are loud.

When cron is the wrong tool

Cron answers "at what wall-clock time should this run". It does not answer:

  • Run X minutes after Y happens. That is a queue with a delay, not a schedule.
  • Retry the last failed run. Cron is fire-and-forget; build retry into the job body or use a queue worker.
  • Only run if Y is still true. Cron does not check preconditions. Add them to the script.
  • Run N copies in parallel. Cron starts one process per fire. If you need fan-out, fan out from the script.
  • Exactly every N minutes regardless of N. Cron has minute precision and cannot express "every 45 minutes". Use a queue with delays.

If your scheduling need is any of the above, reach for SQS + Lambda, Temporal, Inngest, BullMQ, or a real workflow engine. Cron is wonderful at "9 AM every weekday" and miserable at almost anything more complex.

Daylight saving — the time bug

A job scheduled at 30 2 * * * in a DST-observing zone (most of the EU and North America) will, on the spring-forward day, target a wall-clock time that does not exist — clocks jump from 02:00 to 03:00. Different cron implementations handle this differently:

  • Linux vixie cron: runs the job at 03:00 on the gap day.
  • Linux cronie (RHEL): silently skips.
  • Kubernetes CronJob (robfig/cron Go library): silently skips.
  • Quartz: runs at 03:00.

On the fall-back day, the same wall-clock 02:30 happens twice. Some crons fire twice; most fire once. The bullet-proof fix is to schedule in UTC, where DST does not exist. The second-best fix is to avoid the gap window — pick 04:30 instead of 02:30 and you are immune.

Testing your expression

Three layers, in order of cost:

  1. Static validation. Paste it into a tool like this one. Catches field-count errors, out-of-range values, dialect-specific issues (the AWS ? rule, GitHub's 5-minute minimum, Vercel's alphabetic-alias rejection).
  2. Next-run preview. Look at the next ten timestamps. If they don't match what you expected, the expression is wrong.
  3. Smoke test in production. Schedule it at +5 minutes from now for the first run. Watch your logs. If the job fires once correctly, the cron is fine — failures after that are a code problem, not a scheduling problem.

The rest of the lifecycle

A correct expression is a small fraction of running cron in production. The list of things to also get right:

  • Idempotency. If the job fires twice, the second run does nothing harmful. (DST, controller retries, manual triggers all cause double-fires.)
  • Logging. Every run prints its scheduled instant and its exit code.
  • Monitoring. A deadman switch (Healthchecks.io, Better Stack heartbeat) pages you when a run is overdue, because cron never tells you it skipped.
  • Concurrency. Wrap with flock or set concurrencyPolicy: Forbid on Kubernetes, or the slow run from Tuesday piles up under the slow run from Wednesday.
  • Resource limits. Cron jobs run unattended at 03:00. Set a timeout and a memory cap.

Read next

all guides →
Spotted something wrong? Use the contact form.