A cron expression is five numbers that define when a job runs. 0 9 * * 1-5 means: 09:00 every weekday. It's the standard way to schedule periodic tasks in Unix and all cloud services.
The syntax looks simple at first, but the devil is in the details. The difference between 0 9 15 * ? (9:00 on the 15th) and 0 9 ? * 5 (9:00 every Friday) can cost hours of debugging. Misinterpreting timezone can cause a job to run one hour earlier or later due to daylight saving time. This guide covers all the corner cases.
The Five Fields of a Cron Expression
MIN HOUR DOM MON DOW
MIN: minute (0-59)
HOUR: hour (0-23, 24-hour format)
DOM: day of month (1-31)
MON: month (1-12, or JAN-DEC)
DOW: day of week (0-7, where 0=Sunday, 7=Sunday, 1=Monday)
Example: Parsing an Expression
30 14 15 6 *
│ │ │ │ └─ day of week: any (*)
│ │ │ └──── month: June (6)
│ │ └─────── day of month: 15
│ └────────── hour: 14 (2:00 PM)
└───────────── minute: 30
Result: 14:30 (2:30 PM) on June 15 any year, any day of week Key point: if day-of-month AND day-of-week are both filled (not *), they work with OR logic, not AND. The job runs if EITHER day-of-month matches OR day-of-week matches. This is a frequent source of errors.
Special Characters
* (asterisk) — any value
0 * * * * → every hour at the start (00:00, 01:00, 02:00, ...)
* * * * * → every minute
0 0 * * * → every day at midnight
/ (slash) — step/interval
*/5 * * * * → every 5 minutes (0, 5, 10, 15, ...)
0 */4 * * * → every 4 hours (00:00, 04:00, 08:00, ...)
0 0 1 */3 * → every third month on the 1st (January, April, July, ...)
0 0 * */6 * → every 6th month
- (dash) — range
0 9-17 * * * → every hour from 09:00 to 17:00 (business hours)
0 9 * 1-3 * → 09:00 every day from January to March
0 9 * * 1-5 → 09:00 Monday to Friday (weekdays)
, (comma) — list of values
0 9,12,15 * * * → at 09:00, 12:00, 15:00
0 0 1,15 * * → at midnight on the 1st and 15th
0 9 * * 1,3,5 → 09:00 on Monday, Wednesday, Friday
? (question mark) — no value
? is used ONLY in day-of-month OR day-of-week field (never both). It means: "no constraint in this field". You need this to avoid conflicts.
0 9 15 * ? → 09:00 on the 15th (day of week doesn't matter)
0 9 ? * 1 → 09:00 every Monday (day of month doesn't matter)
0 9 ? * 1-5 → 09:00 Monday to Friday
Common Examples
Daily Tasks
0 0 * * * → midnight every day
0 6 * * * → 06:00 every day (dawn, popular for backups)
0 12 * * * → noon every day
0 23 * * * → 23:00 every day
Weekly Tasks
0 9 ? * 1 → 09:00 every Monday
0 9 ? * 1-5 → 09:00 Monday to Friday (weekdays)
0 9 ? * 0 → 09:00 every Sunday
0 9 ? * 0,6 → 09:00 on weekends (Saturday and Sunday)
Monthly Tasks
0 0 1 * ? → midnight on the 1st of every month
0 0 15 * ? → midnight on the 15th of every month
0 0 L * ? → last day of month (some systems support L)
0 0 ? * 1L → last Monday of the month
Interval Tasks
*/5 * * * * → every 5 minutes
0 */6 * * * → every 6 hours (00:00, 06:00, 12:00, 18:00)
0 */4 * * * → every 4 hours
*/30 * * * * → every 30 minutes
15 * * * * → at the 15th minute of every hour
Business Hours
0 9-17 * * 1-5 → every hour from 09:00 to 17:59 on weekdays
0 */2 * * 1-5 → every 2 hours on weekdays
0 18 * * 1-5 → 18:00 (6 PM) on weekdays (end of business)
Shortcuts (@-syntax)
Some systems (Celery, APScheduler, Kubernetes) support shorthand notation:
@yearly or @annually = 0 0 1 1 * (January 1 at midnight)
@monthly = 0 0 1 * * (1st at midnight)
@weekly = 0 0 ? * 0 (Sunday at midnight)
@daily = 0 0 * * * (midnight)
@hourly = 0 * * * * (start of every hour)
@reboot = on system startup (crontab only, not in Celery)
Advanced Format: 6 Fields (with Seconds)
Standard cron has 5 fields (minute, hour, day, month, weekday). Some systems support 6-field format with seconds:
SEC MIN HOUR DOM MON DOW
Celery Beat (Python tasks)
Google Cloud Scheduler (doesn't support, only 5 fields)
Quartz Scheduler (Java, supports 6 fields)
Unix crontab (doesn't support, only 5 fields)
# 6-field format (Celery)
*/15 * * * * * # every 15 seconds
0 */5 * * * * # every 5 minutes Timezone in Cron
Cron runs in a specific timezone. The problem: during daylight saving time shifts, clocks move 1 hour. If your job should run at 2:00 AM and the system jumps from 1:59 AM to 3:00 AM — your job never runs.
UTC vs Local Timezone
Unix crontab: uses local timezone of the server (from /etc/timezone)
Celery Beat: specify timezone explicitly in config
Google Cloud Scheduler: specify timezone explicitly (default: UTC)
AWS EventBridge: uses UTC by default
Kubernetes CronJob: uses UTC always
Correct Configuration in Celery
# celery.py
from celery import Celery
from celery.schedules import crontab
app = Celery('myapp')
# Specify timezone explicitly
app.conf.timezone = 'Europe/Moscow' # or 'America/New_York', 'UTC'
app.conf.beat_schedule = {
'daily-backup': {
'task': 'backup.run',
'schedule': crontab(hour=2, minute=0),
# 2:00 AM Moscow time
},
'hourly-sync': {
'task': 'sync.run',
'schedule': crontab(minute=0),
# Every hour at the start
},
} Always specify timezone explicitly. Don't rely on server defaults.
Common Cron Expression Mistakes
Mistake 1: Wrong day-of-week range
❌ Wrong: 0 9 * * 1-6 (interpreted as Mon-Sat)
❌ Wrong: 0 9 * * 0-5 (interpreted as Sun-Fri, not business days!)
✓ Correct: 0 9 * * 1-5 (Mon-Fri = weekdays)
Mistake 2: Both day and weekday fields filled
❌ Wrong: 0 9 15 * 1
This runs on the 15th OR Monday. If the 15th falls on Thursday, the job runs twice (once on the 15th, once on the following Monday).
✓ Correct: 0 9 15 * ? (exactly on the 15th)
✓ Correct: 0 9 ? * 1 (exactly on Monday)
Mistake 3: Forgot leading zero
❌ Wrong: 9 9 * * *
This means: 09:09 (9th minute of 9th hour), not 09:00
✓ Correct: 0 9 * * * (09:00, zero minute)
Mistake 4: Interval crossing boundary
❌ Wrong: 0 9-18/5 * * *
Systems disagree on interpretation. Usually this means: 09:00, 14:00 (doesn't include end-of-day hour 18).
✓ Correct: 0 9,14 * * * (explicitly list hours)
Mistake 5: Wrong month
❌ Wrong: 0 0 1 12 * (intended: December)
This works, but clarity is lacking. Better to use month names.
✓ Correct: 0 0 1 DEC * or 0 0 1 12 *
Debugging Cron Expressions
Online Validators
Before running cron in production — validate with an online validator. Enter the expression, see the next 10 runs.
crontab.guru — simple, instant feedback, supports 5-field format
Logging in Crontab
# Unix crontab
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
MAILTO=admin@example.com
# Outputs to /var/log/syslog
0 9 * * * /path/to/script.sh
# Or redirect to file
0 9 * * * /path/to/script.sh >> /var/log/my-cron.log 2>&1 Always log cron jobs. Silent failures are the most expensive. If a job didn't run — logs show what happened (permission denied, file not found, non-zero exit code).
Monitoring Cron Jobs with AtomPing
Cron jobs don't send heartbeats automatically. Use Heartbeat check in AtomPing. Your cron job should:
# Python cron task
def my_cron_job():
try:
# Do work
result = expensive_operation()
# Send heartbeat to AtomPing
requests.post(
'https://your-monitor.atomping.com/heartbeat/my-job',
json={'status': 'success', 'result': result}
)
except Exception as e:
# Send failure
requests.post(
'https://your-monitor.atomping.com/heartbeat/my-job',
json={'status': 'failed', 'error': str(e)},
headers={'X-Webhook-Status': 'error'}
)
# In crontab:
0 9 * * * /usr/bin/python3 /path/to/cron_job.py If the heartbeat doesn't arrive within the expected window (e.g., 65 seconds for a job scheduled at 9:00) — AtomPing alerts. This catches:
✓ Job didn't run (cron broken, script deleted)
✓ Job hung (processing takes 10+ minutes)
✓ Job crashed (unhandled exception)
Cron Expressions Checklist
Syntax: are the 5 fields filled correctly (min hour dom mon dow)?
Special characters: use *, -, /, , ? correctly
Day-of-month vs day-of-week: if both filled — use ? in one
Timezone: specify timezone explicitly (don't rely on default)
Validation: validate on crontab.guru before production
Logging: configure output to a log file
Monitoring: set up heartbeat check to track success
Related Articles
Cron Job Monitoring — how to monitor cron jobs with Heartbeat checks
Complete Guide to Uptime Monitoring — overview of monitoring
Heartbeat Check — in AtomPing
FAQ
What is a cron expression?
A cron expression is a string that specifies when a scheduled job should run. It uses 5 fields (minute hour day month weekday) to define the schedule in a compact format. For example: '0 9 * * 1-5' means every weekday at 9:00 AM. Cron expressions are used in Unix/Linux systems, cloud schedulers (AWS EventBridge, Google Cloud Scheduler), and background job frameworks (Celery, APScheduler, node-cron).
What do the 5 fields in cron mean?
Minute (0-59): which minute of the hour. Hour (0-23): which hour of the day (24-hour format). Day of Month (1-31): which day of the month. Month (1-12): which month of the year. Day of Week (0-7): which day of the week (0 and 7 are Sunday). The order matters: '30 14 15 6 *' = 2:30 PM on June 15th of any day of the week.
What do the special characters mean in cron?
* (asterisk) = any value in that field. , (comma) = multiple values (e.g., 1,3,5 = 1st, 3rd, 5th). - (dash) = range (e.g., 1-5 = 1 through 5). / (slash) = step/interval (e.g., */5 = every 5 units). ? (question mark) = no specific value (used only in day or weekday to avoid conflicts). Examples: */30 = every 30 minutes, 0-6 = 0 through 6, 9-17 = 9 AM to 5 PM.
What is the difference between day-of-month and day-of-week?
Both specify 'which days'. Day-of-month (3rd field) = calendar days (15, 16, 17). Day-of-week (5th field) = weekdays (Monday, Tuesday, etc.). If you specify both, cron uses OR logic: the job runs on EITHER the specified day-of-month OR the specified day-of-week. To avoid conflicts, use '?' in one field when the other is specified. '0 9 15 * ?' = 9 AM on the 15th day of the month only.
What's the difference between UTC and local timezone in cron?
Cron expressions are evaluated in a specific timezone. By default, cron uses the system's local timezone. In cloud schedulers (AWS, Google Cloud) and modern frameworks (Celery Beat, APScheduler), you specify the timezone explicitly. If you don't specify, assume UTC. This matters for daylight saving time: when clocks spring forward/back, a '9:00 AM' job might run at 8 AM or 10 AM local time. Always set timezone explicitly in production.
How do you monitor cron jobs with AtomPing?
Cron jobs don't send heartbeats automatically. Use the 'Heartbeat' check type: your cron job should POST to an AtomPing endpoint when it completes (including success/error status). If the heartbeat doesn't arrive within expected window (e.g., 65 seconds for a job that runs every 60 seconds), AtomPing alerts you. This detects failures (job didn't run), hangs (job ran but took too long), and crashes (job exited abnormally).