Cron expressions are the most ubiquitous and least readable string in DevOps. Three decades of muscle memory and most engineers still pause for 10 seconds when they see 0 9 1 * 1-5.
This guide is for the pause. The translator that handles the rest is at /tools/cron-to-english.
The five-field anatomy
* * * * *
| | | | |
| | | | +-- day of week (0-6, 0 is Sunday; or SUN-SAT)
| | | +---- month (1-12; or JAN-DEC)
| | +------ day of month (1-31)
| +-------- hour (0-23)
+---------- minute (0-59)
That is it. Everything else is special syntax on top of these five fields.
The four wildcards you actually use
*matches every value in the field.,lists specific values:1,15,30.-is a range:1-5is Monday through Friday./is a step:*/15in minutes is every 15 minutes.
The 12 expressions you should recognize at a glance
| Expression | English |
|---|---|
* * * * * | Every minute |
*/5 * * * * | Every 5 minutes |
0 * * * * | Every hour on the hour |
*/15 9-17 * * 1-5 | Every 15 minutes during business hours |
0 0 * * * | Daily at midnight UTC |
0 9 * * 1-5 | 9 AM every weekday |
0 0 * * 0 | Every Sunday at midnight |
0 0 1 * * | First of every month |
0 0 1 1 * | First of January at midnight |
30 14 1,15 * * | 2:30 PM on the 1st and 15th |
0 */6 * * * | Every 6 hours |
0 22 * * 5 | 10 PM every Friday |
If you can read these without thinking, you are 80 percent of the way.
The two-field gotcha: day of month vs day of week
When both day-of-month and day-of-week are specified, cron does an OR, not an AND. This is the source of the most common cron bug.
0 0 1 * 1
You might read this as "the 1st of the month if it falls on a Monday." It actually means the 1st of the month, OR every Monday. Both. That is roughly 5 firings a month, not 0 or 1.
If you want AND semantics, use one field and * the other. Otherwise, use systemd timers or AWS EventBridge, both of which interpret this more sanely.
Vendor-specific variations
AWS EventBridge (6-field cron)
EventBridge adds a year field. So the format is six fields, not five:
* * * * * *
| | | | | |
| | | | | +-- year (1970-2199)
| | | | +---- day of week (1-7, 1 is Sunday in EventBridge; or SUN-SAT)
| | | +------ month
| | +-------- day of month
| +---------- hour
+------------ minute
Note that day-of-week is 1-7 in EventBridge, not 0-6. Sunday is 1, not 0. Bugs here are common.
Also: EventBridge does not allow both day-of-month and day-of-week to be * simultaneously. You must specify one or the other with ? to mean "no specific value." Yes, ? is a thing in EventBridge cron.
GitHub Actions
GitHub Actions uses standard 5-field cron and runs in UTC. Important caveat: GitHub Actions cron schedules are not guaranteed to run exactly on time. Under load, scheduled workflows can be delayed by 5 to 20 minutes or skipped entirely. If you need precise scheduling, do not use Actions cron. Use EventBridge or a real scheduler that calls your repo via API.
Kubernetes CronJobs
Standard 5-field cron with a few extra tokens (@yearly, @monthly, @weekly, @daily, @hourly). The container runs at the scheduled time. The catch: a CronJob can fail to start if the cluster is under pressure. Configure startingDeadlineSeconds and concurrencyPolicy appropriately.
Timezone hazards
By default, cron runs in the timezone of the host. AWS EventBridge runs in UTC. GitHub Actions runs in UTC. Linux cron usually runs in the system timezone, but on a server with TZ set wrong, it does not.
The rule I follow: every cron expression in production is in UTC and converted to the local timezone in code if the human-readable representation matters. This eliminates 90 percent of "the job did not fire" tickets.
The 0 9 * * 1-5 expression that means "9 AM every weekday" is 9 AM UTC. That is 4 AM Central, 5 AM Eastern, 10 AM London, 6 PM Tokyo. Plan accordingly.
Daylight saving time hazards
Linux cron handles DST in mostly the right way: if a job is scheduled in the gap (2 AM does not exist on the spring forward day), it is skipped. If it is scheduled in the overlap (1 AM happens twice in fall), behavior depends on the cron implementation. Some run it twice. Some run it once.
Solution: run in UTC. UTC does not have DST. No surprises.
The auditable cron
Three rules I run in every repo:
- Cron expressions live in code, not in the crontab on a server somewhere nobody can find.
- Every cron expression has a comment with the English translation directly above it.
- Every cron-scheduled job emits a heartbeat. If the heartbeat is missing for 2x the expected interval, alert.
This is the difference between cron jobs that run reliably for years and cron jobs that quietly stopped firing in March and nobody noticed until June.
Receipts
- 12 production codebases audited in the last quarter.
- Cron-related bugs found: 7 (5 of those: timezone or DST).
- Cron jobs that had stopped firing without alerting: 3.
- Cron jobs that were firing more often than intended (the dom + dow OR bug): 2.
- Median time to fix a cron bug once identified: 15 minutes.