Cron expressions demystified: five fields, ranges, steps, and the day-of-week OR trap
The five fields and their valid ranges
Every standard cron expression consists of exactly five whitespace-separated fields read left to right: minute (0–59), hour (0–23), day-of-month (1–31), month (1–12), and day-of-week (0–7). Minute is always the leftmost field — a common mistake is to read a cron line starting from the right, which inverts the meaning. The day-of-week field accepts both 0 and 7 to mean Sunday, a deliberate redundancy inherited from Unix tradition that allows either convention without error.
The month field can also accept the three-letter English abbreviations jan through dec, and the day-of-week field accepts sun through sat in implementations that follow Vixie cron conventions (the dominant codebase for Linux cron since the 1990s). Using numeric values is safer for portability across environments. A sixth seconds field is prepended in Quartz Scheduler (used in Java applications), and some cloud schedulers like AWS EventBridge add an optional seventh year field — these extended formats are not interchangeable with the POSIX five-field standard.
Wildcards, comma lists, hyphen ranges, and slash steps
Four special characters control how a field matches: the asterisk (*) matches every valid value for that field — * * * * * fires every minute of every day. The comma (,) creates a list of discrete values: 1,3,5 in the month field means January, March, and May only. The hyphen (-) defines an inclusive range: 9-17 in the hour field matches every hour from 9 through 17, inclusive — useful for 'business hours only' schedules. These three characters can be combined in a single field: 1-5,0 in the day-of-week field matches Monday through Friday plus Sunday.
The slash (/) introduces a step value. When used after an asterisk, */15 in the minute field produces the set {0, 15, 30, 45} — every multiple of 15 from the field minimum. When used after a range, 0-30/5 produces {0, 5, 10, 15, 20, 25, 30} — every fifth value within that range. The step always starts at the left bound of the range (or 0 for *), not at a random offset. This is why */5 in the minute field fires at 0, 5, 10 … regardless of when the cron daemon started or when the job was last added.
The day-of-month and day-of-week OR trap
The single most surprising behavior in cron is how it handles a schedule where both the day-of-month field and the day-of-week field are set to non-wildcard values. Intuitively, you might expect 0 2 15 * 5 to mean 'run at 2:00 AM only on Fridays that fall on the 15th of the month' — an AND condition. In practice, most cron implementations — including Vixie cron and dcron — treat this as an OR condition: the job fires at 2:00 AM on the 15th of every month, and also at 2:00 AM every Friday, regardless of the date. The cron(5) manual page documents this explicitly: 'if both DOM and DOW are specified, the command will be run when either field matches the current time.'
This behavior catches developers when they try to express compound date conditions. If you genuinely need 'the third Friday of every month', cron cannot express it directly. The idiomatic solution is to schedule for every Friday (0 2 * * 5) and add a shell test at the start of the command: [ $(date +\%d) -ge 15 ] && [ $(date +\%d) -le 21 ] && /path/to/script. Alternatively, use a higher-level scheduler that natively supports calendar recurrence rules (RFC 5545 RRULE), such as systemd timer OnCalendar= directives.
Predefined shortcuts: @reboot, @hourly, @daily, @weekly, @monthly
Vixie cron introduced a set of named shortcuts that replace the five-field syntax for common schedules. @reboot runs the job once immediately after the cron daemon itself starts — useful for lightweight startup tasks, though for production services a proper init system (systemd unit, runit, etc.) is preferable. @hourly is equivalent to 0 * * * * — it fires at minute zero of each hour: 00:00, 01:00, 02:00, and so on. @daily (alias @midnight) is equivalent to 0 0 * * *; @weekly to 0 0 * * 0 (Sunday midnight); @monthly to 0 0 1 * * (midnight on the first of each month); and @yearly (alias @annually) to 0 0 1 1 * (midnight on January 1st).
These shortcuts improve readability in crontab files, but they depend on the cron daemon recognising the @ syntax — POSIX does not define them, so they are unavailable in some minimal or embedded cron implementations. When writing cron expressions for cloud schedulers (AWS EventBridge, Google Cloud Scheduler, GitHub Actions), check the provider's documentation for supported syntax, because most cloud platforms do not implement the @ shortcuts and require the explicit five-field form.
Step values and the 'not every N from now' gotcha
A frequent misunderstanding about step syntax is treating */N as 'every N units from when this job was created'. It is not. The step is a modulo filter on absolute time values. */5 in the minute field is shorthand for the set {x : x mod 5 = 0, 0 ≤ x ≤ 59} = {0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55} — twelve fixed clock positions per hour. The cron daemon checks these positions against the current wall-clock minute; it has no memory of when your job was registered.
This produces a subtle problem with 0 */6 * * *. This expression fires at hours 0, 6, 12, and 18 — midnight, 6 AM, noon, and 6 PM — exactly four times per day. If you add this job to your crontab at 3 PM and expect 'the first run six hours from now at 9 PM', you will be surprised: the next run is at 6 PM (hour 18). If you need a run at 9 PM, use the explicit list 0 9,15,21,3 * * * or shift the starting hour with 0 3/6 * * * — but note that START/STEP notation (meaning 'starting at START, every STEP') is a Vixie cron extension and may not be available everywhere; for maximum portability, always use the explicit comma-separated list.
Timezones: the invisible bug in cron schedules
Traditional cron daemons run every job in the system timezone of the machine they run on. On a server configured to UTC — which is the default for most Docker container base images and many cloud VMs — 0 8 * * 1-5 does not mean 8 AM in your business's local time; it means 8 AM UTC. If your team is in UTC+9 (Japan Standard Time), that job fires at 5 PM local time. AWS CloudWatch Events (now EventBridge) cron expressions are documented as UTC-only. GitHub Actions on: schedule: is UTC. Forgetting this costs real money when a weekly billing report runs at 3 AM local time instead of the intended 8 AM.
Daylight Saving Time introduces two further failure modes. When clocks spring forward (e.g., from 01:59 to 03:00), any cron job scheduled during the skipped hour simply does not run that day. When clocks fall back (e.g., from 02:59 to 02:00), the same minute appears twice; whether the job runs once or twice depends on the daemon's implementation. The safest production strategy is to run cron servers in UTC permanently and convert to local time in the application layer, or use a scheduler that accepts an explicit TimeZone parameter, such as systemd timers with TimeZone=, Kubernetes CronJob with .spec.timeZone (added in Kubernetes 1.25), or AWS EventBridge Scheduler which accepts IANA timezone names like America/New_York.