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) and W (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.

More Articles

CSV vs JSON for Data Exchange: When Each Format Wins

A practical comparison of CSV and JSON for APIs, data pipelines, and file exports. Covers structure, parsing, streaming, schema enforcement, size, tooling, and clear guidelines for choosing the right format.

15 April, 2026

SEO for AI Search: How to Optimise for ChatGPT, Perplexity, and Google AI Overviews

How AI-powered search engines discover, evaluate, and cite web content. Practical strategies for optimising your pages for ChatGPT Browse, Perplexity, Google AI Overviews, and other AI answer engines.

14 April, 2026

Image to Base64 Data URIs: When to Inline and When Not To

A practical guide to embedding images as Base64 data URIs. Covers the data URI format, size overhead, performance trade-offs, browser caching, Content Security Policy, and clear rules for when inlining helps vs hurts.

10 April, 2026