Cron is a scheduling syntax that first appeared in 1970s Unix. Half a century later, every modern scheduler — Kubernetes CronJob, GitHub Actions, AWS EventBridge, Cloudflare Workers — still takes cron expressions verbatim. The syntax is simple, but implementations drift, and most production incidents come from timezone or DST surprises. This guide covers the standard 5-field grammar, the implementation differences, and the patterns that fail silently.
The 5-field syntax
Standard Unix cron uses five whitespace-separated fields.
* * * * *
│ │ │ │ │
│ │ │ │ └── day of week (0-7, 0 and 7 both = Sunday)
│ │ │ └─────── month (1-12)
│ │ └──────────── day of month (1-31)
│ └───────────────── hour (0-23)
└────────────────────── minute (0-59)Special characters
| Symbol | Meaning | Example |
|---|---|---|
* | Any value | * * * * * — every minute |
, | List | 0,15,30,45 — at :00/:15/:30/:45 |
- | Range | 9-17 — 9 AM through 5 PM |
/ | Step | */5 — every 5th value |
? | "none" (AWS, Quartz) | For exclusive day/dow |
Common expressions
| Expression | Meaning |
|---|---|
* * * * * | Every minute |
*/5 * * * * | Every 5 minutes |
0 * * * * | Top of every hour |
0 0 * * * | Daily at midnight |
0 9 * * 1-5 | Weekdays at 9 AM |
0 0 1 * * | 1st of each month at midnight |
0 0 * * 0 | Every Sunday at midnight |
30 2 * * * | Every day at 2:30 AM |
Build expressions visually with Cron Expression Builder. Decode and verify them with Cron Expression Parser — it shows a plain-English description plus the next five fire times.
Nicknames
Most implementations accept named shortcuts:
| Name | Equivalent |
|---|---|
@yearly / @annually | 0 0 1 1 * |
@monthly | 0 0 1 * * |
@weekly | 0 0 * * 0 |
@daily / @midnight | 0 0 * * * |
@hourly | 0 * * * * |
@reboot | On daemon startup (cron daemons only) |
Day-of-month + day-of-week — OR or AND?
Cron's biggest gotcha. Does 0 0 13 * 5 fire on the 13th and Fridays, or the 13th or Fridays?
Standard Unix cron is OR. If either day field is not *, a match in either fires the job. The example fires on every 13th plus every Friday. Friday the 13th matches both but still runs once.
Quartz (the Java world) and AWS EventBridge are AND or simply forbid specifying both — that's why ? exists. Per implementation:
- Unix cron, crontab — OR
- Kubernetes CronJob — OR (Unix-based)
- GitHub Actions — OR (Unix-based)
- AWS EventBridge / CloudWatch — must use
?in one field - Quartz — must use
?in one field
6 / 7-field variants
Some implementations add a seconds field at the start or a year field at the end.
- AWS EventBridge / Quartz — 6 fields (sec/min/hr/dom/ mon/dow/year) or 7 fields
- Spring Scheduling — 6 fields (seconds + standard 5)
Always check the field count before copying an expression across systems. Pasting Spring's 0 0 9 * * * into Unix cron misreads as "minute 0, hour 0, day 9, every month".
Timezones — source of 80% of incidents
Unix cron
Runs in the system's local timezone. TZ=Asia/Seoul overrides it. Containers (UTC by default) versus host can yield a 9-hour offset if you don't notice.
Kubernetes CronJob
spec.timeZone field is stable in 1.27+. Without it, kube-controller-manager's local (usually UTC) is used. Always set it explicitly.
GitHub Actions
UTC, period. For daily 9 AM Pacific, write 0 17 * * * (UTC 5 PM in winter — and remember PT shifts with DST).
AWS EventBridge
Cron is UTC-only. For other zones, schedule in UTC and convert inside the handler, or use rate() for interval-based.
DST pitfalls
Korea and most of East Asia don't observe DST, so this hits global apps schedules running in zones like America/New_York or Europe/London.
- Spring forward — 2:00 jumps to 3:00.
30 2 * * *just doesn't fire that day. - Fall back — 2:00 happens twice. The same cron can fire twice.
Fixes: (1) use a non-DST timezone like UTC or Asia/Seoul; (2) if you must use a DST zone, avoid scheduling between midnight and 3 AM — past 4 AM is safe.
Common pitfalls
1. 30/5 * * * *
Intended as "starting at minute 30, every 5 min". Vixie/cronie reads it as 30/35/40/45/50/55. Quartz parses differently. Use step only with */N to stay portable.
2. Day 31 or Feb 30
0 0 31 * * never fires in months without a 31st. There is no "last day of month" in standard cron — schedule daily and check $(date +%d) -eq $(date -d tomorrow +%d) in the job.
3. Overlapping runs
Cron does not check whether the previous run finished. Pinning an hour-long job to 0 * * * * stacks overlapping copies. Guard with a lock file or flock.
4. Missing env vars
The cron daemon runs jobs in a minimal environment — empty PATH, LANG, HOME. "It works in the shell but cron can't find node" is the classic failure. Either set env vars on the cron line, or source your profile at the top of the script.
5. Silent failures
Cron jobs' stdout/stderr go to system mail, which is rarely configured in containers. Append >> /var/log/myjob.log 2>&1 to every cron line, or ping a healthcheck service (Healthchecks.io, Dead Man's Snitch).
Try it
- Cron Expression Parser — paste an expression to get a description plus the next five fire times.
- Cron Expression Builder — checkbox-driven builder. Great for "every Friday at 6 PM" intent.
- Time Zone Converter — convert between UTC and your local zone when picking the right cron time.
- Unix Timestamp Converter — read timestamps out of cron job logs.
Recap
- Standard is 5 fields (min/hr/dom/mon/dow). AWS and Quartz use 6/7 — confirm before porting.
- Day-of-month + day-of-week is OR on Unix, must use
?on AWS/Quartz. - GitHub Actions and AWS run in UTC. Kubernetes needs explicit
spec.timeZone. Always specify. - In DST timezones, avoid the 0–3 AM window.
- Long jobs need lock + log + healthcheck — the trio that catches silent failures.