Skip to content
yutils

Cron Expression Guide — Syntax, Timezones, and Common Mistakes

Cron field-by-field syntax, special tokens, timezone behavior across Unix cron, Kubernetes, and AWS, and the patterns that cause silent failures.

~8 min read

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

SymbolMeaningExample
*Any value* * * * * — every minute
,List0,15,30,45 — at :00/:15/:30/:45
-Range9-17 — 9 AM through 5 PM
/Step*/5 — every 5th value
?"none" (AWS, Quartz)For exclusive day/dow

Common expressions

ExpressionMeaning
* * * * *Every minute
*/5 * * * *Every 5 minutes
0 * * * *Top of every hour
0 0 * * *Daily at midnight
0 9 * * 1-5Weekdays at 9 AM
0 0 1 * *1st of each month at midnight
0 0 * * 0Every 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:

NameEquivalent
@yearly / @annually0 0 1 1 *
@monthly0 0 1 * *
@weekly0 0 * * 0
@daily / @midnight0 0 * * *
@hourly0 * * * *
@rebootOn 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

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.
Back to guides