← Back to blog
·7 min read

Cron Expression Guide: From Basics to Advanced Scheduling

Cron expressions define when scheduled jobs run. They look cryptic at first — 0 /6 * 1-5 — but follow a simple structure. This guide covers everything from basics to edge cases that trip up experienced developers.

The five fields

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-7, 0 and 7 = Sunday)
│ │ │ │ │
* * * * *

Each field accepts:

  • A number: 5 — matches exactly 5
  • A wildcard: * — matches every value
  • A range: 1-5 — matches 1 through 5
  • A list: 1,3,5 — matches 1, 3, and 5
  • A step: */15 — matches every 15th value (0, 15, 30, 45)

20+ real-world examples

Every X minutes

bash
# Every minute
* * * * *

# Every 5 minutes
*/5 * * * *

# Every 15 minutes
*/15 * * * *

# Every 30 minutes
*/30 * * * *

Hourly

bash
# Every hour (at minute 0)
0 * * * *

# Every 2 hours
0 */2 * * *

# Every 6 hours
0 */6 * * *

# Every hour from 9 AM to 5 PM
0 9-17 * * *

Daily

bash
# Every day at midnight
0 0 * * *

# Every day at 3:30 AM
30 3 * * *

# Every day at 6 AM and 6 PM
0 6,18 * * *

# Twice a day at 8 AM and 8 PM
0 8,20 * * *

Weekly

bash
# Every Monday at 9 AM
0 9 * * 1

# Every weekday at 8 AM
0 8 * * 1-5

# Every weekend at noon
0 12 * * 0,6

# Every Friday at 5 PM
0 17 * * 5

Monthly

bash
# First day of every month at midnight
0 0 1 * *

# 15th of every month at 3 AM
0 3 15 * *

# Last weekday of every month (approximate — use 28th)
0 0 28 * *

# Every quarter (Jan, Apr, Jul, Oct) on the 1st
0 0 1 1,4,7,10 *

Advanced patterns

bash
# Every 10 minutes during business hours on weekdays
*/10 9-17 * * 1-5

# Every 5 minutes on the first of each month
*/5 * 1 * *

# At 2:15 AM every day
15 2 * * *

# Every Sunday at 4 AM (weekly maintenance window)
0 4 * * 0

Understanding step values

The / operator is the most misunderstood part of cron expressions.

*/15 in the minute field means "every 15 minutes starting from 0": minutes 0, 15, 30, 45.

But 5/15 means "every 15 minutes starting from 5": minutes 5, 20, 35, 50.

bash
# Every 15 minutes: :00, :15, :30, :45
*/15 * * * *

# Every 15 minutes starting at :05: :05, :20, :35, :50
5/15 * * * *

This is useful when you want to offset jobs to avoid running everything at the top of the hour.

Common gotchas

Gotcha 1: Timezone matters

Cron runs in the system's timezone (check with timedatectl or date +%Z). If your server is in UTC but you want a job at 9 AM Eastern:

bash
# 9 AM ET = 2 PM UTC (during EST)
# 9 AM ET = 1 PM UTC (during EDT)
0 14 * * *  # This is wrong half the year!

Fix: Either set the system timezone to your target timezone, or use UTC and accept that the local time shifts with DST.

On systems with CRON_TZ support:

bash
CRON_TZ=America/New_York
0 9 * * *  # 9 AM Eastern, adjusts for DST automatically

Gotcha 2: DST transitions

During the spring-forward transition (e.g., 2 AM jumps to 3 AM):

  • A job scheduled for 2:30 AM is skipped — that time doesn't exist
  • A job scheduled for 3:00 AM runs normally

During the fall-back transition (e.g., 2 AM repeats):

  • A job scheduled for 1:30 AM may run twice — once before and once after the clock change

Fix: Schedule critical jobs outside the 1-3 AM window, or use UTC.

Gotcha 3: Day of month vs. day of week

When both day-of-month and day-of-week are specified (not *), cron runs the job when either condition is true, not both.

bash
# This runs on the 15th AND every Monday — not "the 15th if it's a Monday"
0 0 15 * 1

This is a cron specification quirk that trips up almost everyone. There's no standard way to say "the first Monday of the month" in a single cron expression.

Workaround: Use a wrapper script:

bash
# Run every Monday, but only execute on the first Monday
0 9 1-7 * 1  # Runs on Mondays that fall on the 1st-7th

# Or use a script check
0 9 * * 1
# In your script:
[ $(date +%d) -le 7 ] || exit 0  # Skip if not the first week

Gotcha 4: February and the 31st

bash
# This runs only in months that have a 31st day
0 0 31 * *  # Skips Feb, Apr, Jun, Sep, Nov

If you want "last day of month" you need a script check:

bash
# Run daily, check if tomorrow is the 1st
0 0 * * *
# In script:
[ "$(date -d tomorrow +%d)" = "01" ] || exit 0

Gotcha 5: Leap years

bash
# This runs Feb 29 only on leap years — silently skipped other years
0 0 29 2 *

If you have a job that must run "at the end of February", use the tomorrow-is-first trick above.

Cron expression cheat sheet

ExpressionDescription
`* * * * *`Every minute
`*/5 * * * *`Every 5 minutes
`0 * * * *`Every hour
`0 0 * * *`Every day at midnight
`0 0 * * 0`Every Sunday at midnight
`0 0 1 * *`First of every month
`0 0 1 1 *`January 1st at midnight
`0 9-17 * * 1-5`Every hour, 9-5, weekdays
`*/15 * * * *`Every 15 minutes
`0 */6 * * *`Every 6 hours
`30 4 * * *`4:30 AM daily
`0 0 15 * *`15th of each month

Validating your expressions

Before deploying, test your cron expression:

bash
# Using crontab guru (or similar tools)
# Expression: */15 9-17 * * 1-5
# "Every 15 minutes, 9 AM to 5 PM, Monday through Friday"

When you set up monitoring in CronSafe, it parses your cron expression and shows you the human-readable schedule. If the description doesn't match your intent, you know the expression is wrong before deploying.

Monitoring your scheduled jobs

Whatever cron expression you use, add heartbeat monitoring. The expression defines when the job should run. CronSafe verifies that it actually ran. The combination of correct scheduling and active monitoring eliminates silent failures.

Start monitoring your cron jobs for free

20 monitors, email alerts, GitHub badges. No credit card required.

Get started free →