Cron Expressions Explained — Scheduling Done Right
Cron syntax, common patterns, and platform-specific quirks for Linux, GitHub Actions, Kubernetes, and AWS.

You need a DB backup at 3 AM every night. Or cache invalidation every hour. Or a weekly report email on Monday mornings. Cron handles all of that. It's the backbone of task scheduling on Linux, and the same expression syntax shows up in GitHub Actions, Kubernetes CronJobs, and AWS EventBridge.
The syntax itself is compact — five fields, a handful of operators — but platform-specific quirks catch people off guard. This guide covers the standard format and the ways it varies across different environments.
The 5-Field Format
A standard cron expression has five fields:
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, 0=Sunday)
│ │ │ │ │
* * * * *
Each field supports these operators:
| Symbol | Meaning | Example |
|---|---|---|
* | Every value | * * * * * = every minute |
| number | Specific value | 30 9 * * * = 9:30 AM daily |
, | Multiple values | 0,30 * * * * = top and bottom of every hour |
- | Range | 1-5 = 1 through 5 |
/ | Step | */10 * * * * = every 10 minutes |
Common Patterns
Time-Based
* * * * * every minute
*/5 * * * * every 5 minutes
0 * * * * top of every hour
0 */2 * * * every 2 hours
30 9 * * * 9:30 AM daily
0 0 * * * midnight daily
0 9,18 * * * 9 AM and 6 PM daily
Day-of-Week
0 9 * * 1 Monday at 9 AM
0 9 * * 1-5 weekdays at 9 AM
0 0 * * 0 Sunday at midnight
0 18 * * 5 Friday at 6 PM
Date-Based
0 0 1 * * first of every month at midnight
0 0 1,15 * * 1st and 15th at midnight
0 9 1 1 * January 1st at 9 AM
0 0 * * 1#1 first Monday of the month (some systems)
Working with crontab
On Linux, crontab manages your scheduled jobs.
# list current user's cron jobs
crontab -l
# edit cron jobs
crontab -e
# view another user's cron jobs (requires root)
sudo crontab -u www-data -l
Inside the crontab file, one job per line:
# DB backup at 3 AM daily
0 3 * * * /home/user/scripts/backup.sh >> /var/log/backup.log 2>&1
# health check every 10 minutes
*/10 * * * * curl -s https://mysite.com/health > /dev/null
That >> /var/log/backup.log 2>&1 part redirects both stdout and stderr to a log file. Skip it and cron might try to send the output as email — not what you want on most systems.
Gotchas
Timezone. Cron runs in the system's timezone. A server set to UTC means 0 9 * * * fires at 9 AM UTC, not your local time.
timedatectl # check system timezone
Environment variables. Cron doesn't load your shell profile. PATH, HOME, and other variables might differ from what you expect. Always use absolute paths in your scripts.
# set environment variables at the top of crontab
PATH=/usr/local/bin:/usr/bin:/bin
Overlapping runs. If a job takes longer than the interval between runs, executions can pile up. Use flock to prevent that:
*/5 * * * * flock -n /tmp/myjob.lock /home/user/scripts/slow-job.sh
5-Field vs 6-Field vs 7-Field
Not all cron expressions are created equal. The number of fields depends on the platform:
| Fields | Layout | Used By |
|---|---|---|
| 5 | min hour day month weekday | Linux crontab, GitHub Actions |
| 6 | sec min hour day month weekday | Spring @Scheduled, Quartz |
| 6 (variant) | min hour day month weekday year | AWS EventBridge |
In Spring Boot, @Scheduled(cron = "0 0 9 * * MON") starts with a seconds field. If you write it thinking the first field is minutes (like Linux crontab), you'll get unexpected results. This is one of the most common mistakes when moving cron expressions between systems.
Cron in GitHub Actions
GitHub Actions uses cron syntax in its schedule trigger.
on:
schedule:
- cron: '0 9 * * 1' # Monday at 9 AM UTC
Two things to watch out for: the timezone is always UTC, and there's no guarantee of exact timing. Under heavy load, scheduled workflows can be delayed by minutes or even longer.
Kubernetes CronJobs
Same expression syntax, different context:
apiVersion: batch/v1
kind: CronJob
metadata:
name: db-backup
spec:
schedule: "0 3 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: backup
image: my-backup-image
restartPolicy: OnFailure
The concurrencyPolicy field controls what happens when the previous run is still going: Allow (run both), Forbid (skip the new one), or Replace (kill the old one and start fresh).
AWS EventBridge (CloudWatch Events)
AWS uses a 6-field format — it adds a year field:
cron(minute hour day month day-of-week year)
cron(0 9 ? * MON-FRI *) # weekdays at 9 AM UTC
The ? means "no specific value" and must appear in either the day-of-month or day-of-week field. The syntax diverges from standard cron in a few other ways too, so always double-check the AWS docs when writing these.
Debugging
When a cron job isn't running, here's the checklist:
# is the cron service running?
systemctl status cron
# check cron execution logs
grep CRON /var/log/syslog
# make sure the script is executable
chmod +x /home/user/scripts/backup.sh
Permission issues are the most common culprit. If the script runs fine manually but fails under cron, it's almost always a PATH or file permission problem.
To verify your expression does what you think it does before deploying, try a Cron Expression Generator. Set each field through the UI and it'll show you the next execution times. Handy for complex expressions like */15 3-6 * * 1-5.
Common Mistakes in Practice
Scheduling on the 31st. 0 0 31 * * simply doesn't fire in months that don't have 31 days (February, April, June, etc.). If you need an end-of-month job, schedule it on the 1st and have the script process the previous day's data.
Day-of-month AND day-of-week together. In standard cron, 0 9 15 * 1 means "the 15th OR any Monday" — it's an OR condition, not AND. This trips people up regularly. AWS EventBridge forces you to use ? in one of those fields to avoid the ambiguity entirely.
Forgetting about DST. If your server's timezone observes daylight saving time, a job scheduled at 2:30 AM might get skipped (spring forward) or run twice (fall back). For critical jobs, scheduling in UTC avoids this problem.
systemd Timers — A Modern Alternative
On newer Linux distributions, systemd timers offer a more integrated approach to scheduling:
- Logs go straight to
journalctl— no separate log file management - You can set service dependencies (e.g., wait for network to be up)
OnBootSec,OnCalendar, and other flexible schedule options
# /etc/systemd/system/backup.timer
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target
Persistent=true means if the server was off during a scheduled run, systemd will execute the missed job after boot. Cron doesn't have this — if the machine is down when a job was supposed to run, it's just skipped.
Cron is simpler and more universal, so it's not going anywhere. But if you're on a systemd-based server and want better logging and dependency management, timers are worth considering.