Cron Expressions: A Complete Guide to Scheduling Syntax
6 March, 2026 DevOps
Cron is the backbone of scheduled automation on Unix-like systems. Whether you need to run a database backup at 2 AM, send a weekly digest every Monday morning, or rotate log files on the first of each month, cron expressions give you a compact, declarative way to express that intent. This guide covers everything from the basics of the five-field syntax to timezone pitfalls, cloud scheduler quirks, and the most common mistakes developers make.
What Is Cron
Cron is a time-based job scheduler found in virtually every Unix-like operating system. The name comes from the Greek word for time, chronos. The original cron implementation dates back to the 1970s AT&T Unix, but the version most developers encounter today is Vixie Cron, written by Paul Vixie in 1987 and refined through the 1990s. Vixie Cron introduced per-user crontabs, the MAILTO variable, and the @ shorthand schedules.
The cron daemon (crond) runs continuously in the background. Every minute it wakes up, reads all crontab files, and checks whether any job's schedule matches the current time. If it does, the job is executed as a subprocess. This wake-up-every-minute design is fundamental: cron has one-minute granularity by design - you cannot natively schedule a job to run every 30 seconds.
Working with Crontabs
Each user has their own crontab file. To edit it:
crontab -e # open your crontab in $EDITOR
crontab -l # list current crontab
crontab -r # remove your crontab (careful!)
crontab -u www-data -e # edit another user's crontab (root only)
System-wide cron jobs live in /etc/cron.d/, /etc/cron.daily/, /etc/cron.weekly/, and /etc/cron.monthly/. The system crontab at /etc/crontab has an extra field for the username.
# /etc/crontab has an extra "user" field:
# min hour dom month dow user command
0 2 * * * root /usr/local/bin/backup.sh
# /etc/cron.d/ files follow the same format
The Five Fields
A standard cron expression consists of five whitespace-separated fields followed by the command to execute.
.---------------- minute (0 - 59)
| .------------- hour (0 - 23)
| | .---------- day of month (1 - 31)
| | | .------- month (1 - 12)
| | | | .---- day of week (0 - 7) (Sunday = 0 or 7)
| | | | |
* * * * * command to execute
Field Reference
| Field | Position | Allowed Values | Notes |
|---|---|---|---|
| Minute | 1 | 0-59 | |
| Hour | 2 | 0-23 | 24-hour clock |
| Day of Month | 3 | 1-31 | Not all months have 31 days |
| Month | 4 | 1-12 or JAN-DEC | Three-letter abbreviations OK |
| Day of Week | 5 | 0-7 or SUN-SAT | Both 0 and 7 represent Sunday |
Important: Both 0 and 7 mean Sunday in the day-of-week field. This is a common source of confusion - 0 is the POSIX standard, 7 is a Vixie Cron extension. Either works on most systems.
Month and day-of-week fields accept three-letter English abbreviations: JAN, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC and SUN, MON, TUE, WED, THU, FRI, SAT.
Special Characters
Asterisk * - Any Value
An asterisk matches every possible value for that field. * * * * * means "every minute of every hour of every day."
# Run every minute (useful for testing, rarely for production)
* * * * * /usr/local/bin/heartbeat.sh
# Run at 3:00 AM every day
0 3 * * * /usr/local/bin/backup.sh
# Run at noon every day in July
0 12 * * * JUL /usr/local/bin/summer-report.sh
Slash / - Step Values
The slash defines a step (interval) within a range. The syntax is range/step or */step.
# Every 5 minutes
*/5 * * * * /usr/local/bin/poll.sh
# Every 2 hours, starting at midnight
0 */2 * * * /usr/local/bin/sync.sh
# Every 10 minutes between minute 0 and 30
0-30/10 * * * * /usr/local/bin/task.sh
# Runs at :00, :10, :20, :30 of each hour
Note: */1 is identical to *. Using */1 is a common but harmless redundancy.
Comma , - List of Values
Commas let you specify multiple discrete values in one field.
# At 9 AM and 5 PM every day
0 9,17 * * * /usr/local/bin/reminder.sh
# Every Monday and Friday
0 9 * * 1,5 /usr/local/bin/standup.sh
# In January, April, July, October (quarterly)
0 0 1 1,4,7,10 * /usr/local/bin/quarterly-report.sh
Hyphen - - Ranges
A hyphen defines a continuous range of values.
# Every minute from :00 to :30 (first half of each hour)
0-30 * * * * /usr/local/bin/first-half.sh
# Monday through Friday at 8 AM
0 8 * * 1-5 /usr/local/bin/workday-start.sh
# Every hour from 9 AM to 5 PM on weekdays
0 9-17 * * 1-5 /usr/local/bin/business-hours.sh
Question Mark ? - No Specific Value
The ? character is supported by some cron implementations (notably Quartz Scheduler and AWS EventBridge) to mean "I don't care about this field." It is typically used in the day-of-month or day-of-week field when only one of them needs a specific value.
# Quartz: at 10:15 AM on the 15th, regardless of day of week
0 15 10 15 * ?
# Quartz: at 10:15 AM every Monday, regardless of day of month
0 15 10 ? * MON
Standard Vixie Cron does not support ?. Attempting to use it on Linux crontabs will cause an error.
Predefined Schedules
Vixie Cron introduced @ shorthand aliases for common schedules. These are easier to read and less error-prone than typing out the five fields manually.
| Shorthand | Equivalent | Meaning |
|---|---|---|
@reboot |
(none) | Once, at daemon startup |
@yearly |
0 0 1 1 * |
Once a year, January 1st at midnight |
@annually |
0 0 1 1 * |
Same as @yearly |
@monthly |
0 0 1 * * |
Once a month, 1st at midnight |
@weekly |
0 0 * * 0 |
Once a week, Sunday at midnight |
@daily |
0 0 * * * |
Once a day, midnight |
@midnight |
0 0 * * * |
Same as @daily |
@hourly |
0 * * * * |
Once an hour, at :00 |
# Run backup at system boot
@reboot /usr/local/bin/start-backup-daemon.sh
# Send annual report
@yearly /usr/local/bin/annual-report.sh
# Clean temp files daily at midnight
@daily /usr/local/bin/clean-temp.sh
# Reload config hourly
@hourly /usr/local/bin/reload-config.sh
Examples Reference Table
| Schedule | Cron Expression | Description |
|---|---|---|
| Every minute | * * * * * |
Useful for testing, rarely for production |
| Every 5 minutes | */5 * * * * |
Polling, health checks |
| Every 15 minutes | */15 * * * * |
Cache refresh, metrics collection |
| Every hour (on the hour) | 0 * * * * |
Hourly reports, sync |
| Every 2 hours | 0 */2 * * * |
Less frequent sync |
| Daily at 9 AM | 0 9 * * * |
Morning digest |
| Weekdays at 9 AM | 0 9 * * 1-5 |
Business hours start notification |
| Weekdays at 9 AM and 5 PM | 0 9,17 * * 1-5 |
Shift start/end reminder |
| First of each month at midnight | 0 0 1 * * |
Monthly billing, reports |
| 1st and 15th of each month | 0 0 1,15 * * |
Bi-monthly tasks |
| Every Sunday at midnight | 0 0 * * 0 |
Weekly maintenance window |
| January 1st at midnight | 0 0 1 1 * |
New Year's job |
| Every Monday at 6 AM | 0 6 * * 1 |
Weekly kickoff |
| Every 30 min during business hours | */30 9-17 * * 1-5 |
Frequent polling during work hours |
| Every day at 2:30 AM | 30 2 * * * |
Off-peak database maintenance |
| Every 10 minutes, 8 AM to 6 PM | */10 8-18 * * * |
Daytime-only polling |
Timezone Pitfalls
Cron runs in the timezone of the server it is installed on. This creates several problems.
Server TZ vs Application TZ
Your application may be configured for one timezone (say, America/New_York) while the server runs in UTC. A cron job scheduled for 0 9 * * * will fire at 9 AM UTC, which is 4 AM or 5 AM New York time depending on DST. Always verify what timezone your cron daemon is using:
# Check system timezone
timedatectl status
# or
cat /etc/timezone
Daylight Saving Time
DST transitions cause two distinct failure modes:
Spring forward (clocks move ahead 1 hour): If your server is in America/New_York and clocks jump from 1:59 AM to 3:00 AM, any job scheduled for 2:30 AM will be silently skipped. The time 2:30 AM simply does not exist that day.
Fall back (clocks move back 1 hour): When clocks fall from 1:59 AM back to 1:00 AM, jobs scheduled at 1:30 AM will run twice - once in the first 1:30 AM and once in the second 1:30 AM. For idempotent jobs this is harmless; for non-idempotent ones (sending emails, processing payments), it can be a serious bug.
Best Practice: Use UTC
The safest approach is to configure your server to run in UTC and schedule all cron jobs in UTC. UTC has no DST transitions, so there are no skipped or doubled runs.
# Set system timezone to UTC
sudo timedatectl set-timezone UTC
The CRON_TZ Variable
Some cron implementations (including Vixie Cron on many Linux distributions) support a CRON_TZ variable in the crontab to override the schedule timezone per-entry:
# This job runs at 9 AM New York time regardless of server timezone
CRON_TZ=America/New_York
0 9 * * 1-5 /usr/local/bin/nyc-report.sh
# Reset for the next job
CRON_TZ=UTC
0 14 * * * /usr/local/bin/global-sync.sh
Note: CRON_TZ only affects when the job fires. The job itself still inherits the system timezone as its TZ environment variable unless you set that separately.
Cloud Schedulers
Cloud platforms ship their own scheduling services, each with variations on the cron syntax.
AWS EventBridge (CloudWatch Events)
AWS uses a 6-field cron expression with an extra Year field at the end. Additionally, you must use ? in either the day-of-month or day-of-week field (not both).
# AWS format: minute hour day-of-month month day-of-week year
cron(0 9 ? * MON-FRI *)
# "Every weekday at 9 AM (UTC), any year"
cron(0 12 1 * ? *)
# "1st of every month at noon UTC"
Key differences from standard cron:
- Day-of-week values:
1=Sunday, 2=Monday, ..., 7=Saturday(different from Unix!) - Supports
L(last) andW(nearest weekday) special characters - Minimum frequency: 1 minute
- All times are UTC
GCP Cloud Scheduler
Google Cloud Scheduler uses the standard 5-field Unix cron syntax and natively supports timezone configuration - no UTC-only constraint.
# Cron expression in standard format
*/5 * * * *
# Timezone is set in the scheduler job configuration, not inline
# Example gcloud command:
gcloud scheduler jobs create http my-job \
--schedule="0 9 * * 1-5" \
--time-zone="America/New_York" \
--uri="https://my-service/trigger"
GitHub Actions
GitHub Actions uses standard 5-field cron syntax in the schedule trigger. Key constraints:
- All times are UTC only - no timezone support
- The shortest interval GitHub will reliably honor is 5 minutes, though
* * * * *is syntactically valid - During high load, GitHub may delay scheduled runs by up to several minutes
on:
schedule:
# Run every day at 6 AM UTC
- cron: '0 6 * * *'
# Run every Monday at 9 AM UTC
- cron: '0 9 * * 1'
Quartz Scheduler (Java)
Quartz is a popular Java job scheduling library with a 6 or 7-field cron expression. The key difference: seconds come first.
# Quartz format: second minute hour day-of-month month day-of-week [year]
0 0 12 * * ? # Every day at noon
0 15 10 ? * MON-FRI # Weekdays at 10:15 AM
0 0/5 14 * * ? # Every 5 min from 2 PM to 2:55 PM daily
0 10,44 14 ? 3 WED # 2:10 PM and 2:44 PM every Wednesday in March
Quartz also supports L (last day of month or last specific weekday), W (nearest weekday), and # (nth weekday of month - e.g., FRI#2 for second Friday).
Testing Cron Expressions
Before deploying a cron job, always verify that your expression fires when you expect it to.
Python: croniter
The croniter library lets you iterate over the next (or previous) fire times for a cron expression:
from croniter import croniter
from datetime import datetime
# Check the next 5 fire times for "every weekday at 9 AM"
expr = "0 9 * * 1-5"
base = datetime(2026, 3, 1, 0, 0)
cron = croniter(expr, base)
for _ in range(5):
print(cron.get_next(datetime))
# Output:
# 2026-03-02 09:00:00 (Monday)
# 2026-03-03 09:00:00 (Tuesday)
# 2026-03-04 09:00:00 (Wednesday)
# 2026-03-05 09:00:00 (Thursday)
# 2026-03-06 09:00:00 (Friday)
pip install croniter
Online Cron Tester
For quick validation without writing code, use the interactive cron expression tester. It shows you the next N fire times instantly and highlights any syntax errors.
Common Mistakes
1. Impossible Dates (Silent Failures)
# This will NEVER run - February 30 does not exist
0 0 30 2 * /usr/local/bin/my-job.sh
Cron does not raise an error for impossible dates. The job is simply never triggered. Always double-check day-of-month values against the actual calendar for that month.
2. */1 vs *
*/1 and * are functionally identical. Using */1 is not wrong, but it is redundant and suggests a misunderstanding of the step syntax. Write * when you mean "every."
# These are identical:
*/1 * * * * /usr/local/bin/heartbeat.sh
* * * * * /usr/local/bin/heartbeat.sh
3. The OR Behaviour of Day-of-Month and Day-of-Week
This is one of the most counterintuitive aspects of cron. When you specify a non-wildcard value in both day-of-month and day-of-week, cron uses OR logic, not AND.
# Intended: "1st of January AND that day is a Monday"
# Actual: "1st of January OR any Monday in January"
0 0 1 1 1 /usr/local/bin/wrong.sh
# To run only when January 1st is a Monday, use a script wrapper:
0 0 1 1 * [ $(date +\%u) -eq 1 ] && /usr/local/bin/correct.sh
Example: 0 0 15 * 1 fires on the 15th of every month AND every Monday - not just Mondays that fall on the 15th.
4. Sub-Minute Precision
Cron wakes up once per minute. You cannot natively schedule a job to run every 10 seconds or every 30 seconds. Workarounds include:
# Run every 30 seconds using two entries offset by 30 seconds via sleep
* * * * * /usr/local/bin/job.sh
* * * * * sleep 30; /usr/local/bin/job.sh
For sub-minute scheduling, consider a purpose-built tool like systemd timers with OnCalendar=*:*:0/30, or an application-level scheduler.
5. Missing Output Redirection
By default, cron emails the output of every job to the user's local mailbox (or the MAILTO variable). If your mail system is not configured, output is silently lost or accumulates in a spool. Always redirect output explicitly:
# Discard all output
0 3 * * * /usr/local/bin/backup.sh > /dev/null 2>&1
# Log to a file with timestamp
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
# Capture stderr separately
0 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>> /var/log/backup-errors.log
6. Missing Environment Variables
Cron runs with a minimal environment - no .bashrc, no .profile, no PATH beyond /usr/bin:/bin. Commands that work in your shell may fail silently in cron.
# Bad - relies on PATH including /usr/local/bin
0 3 * * * python3 my_script.py
# Good - use absolute paths
0 3 * * * /usr/bin/python3 /home/deploy/scripts/my_script.py
# Or set PATH at the top of the crontab
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Scheduling in Application Code
Modern frameworks provide scheduler abstractions that translate directly to cron expressions.
Symfony Scheduler
Symfony 6.3+ includes a built-in Scheduler component:
<?php
declare(strict_types=1);
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
#[AsSchedule]
final class AppSchedule implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return (new Schedule())
// Every weekday at 9 AM
->add(RecurringMessage::cron('0 9 * * 1-5', new SendMorningDigestMessage()))
// Every hour
->add(RecurringMessage::cron('@hourly', new SyncInventoryMessage()))
// 1st of each month at midnight
->add(RecurringMessage::cron('0 0 1 * *', new GenerateMonthlyReportMessage()));
}
}
Python: APScheduler
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
scheduler = BlockingScheduler()
# Every weekday at 9 AM in New York time
@scheduler.scheduled_job(
CronTrigger(
day_of_week='mon-fri',
hour=9,
minute=0,
timezone='America/New_York'
)
)
def send_morning_digest():
print("Sending morning digest...")
# Every 5 minutes
@scheduler.scheduled_job(CronTrigger.from_crontab('*/5 * * * *'))
def poll_for_updates():
print("Polling...")
scheduler.start()
What to Keep in Mind
Cron expressions are a compact, powerful scheduling DSL that has stood the test of time since 1975. Mastering the five-field syntax, understanding the OR behaviour of date/weekday fields, choosing UTC for all scheduling, and knowing how your target platform (Linux cron, AWS EventBridge, GCP Cloud Scheduler, GitHub Actions, Quartz) extends or deviates from the standard will save you from the subtle, hard-to-debug failures that catch even experienced developers off guard.