Skip to content
utility2026-07-256 min read

Cron expressions look intimidating—five fields of asterisks, slashes, and numbers that somehow encode "run every Tuesday at 3:30 AM." But once you understand the structure, reading and writing cron schedules becomes straightforward.

This guide breaks down cron expression syntax from the ground up, covers the patterns you'll actually use in production, and highlights the one gotcha that catches almost everyone.

The Five Fields

A standard cron expression has five space-separated fields:

┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12 or JAN-DEC)
│ │ │ │ ┌───────────── day of week (0-7 or SUN-SAT)
│ │ │ │ │
* * * * *

Each field accepts:

| Syntax | Meaning | Example | |--------|---------|---------| | * | Every value | * * * * * = every minute | | 5 | Specific value | 5 * * * * = at minute 5 | | 1,15,30 | List | 0 9,17 * * * = 9 AM and 5 PM | | 1-5 | Range | 0 9 * * 1-5 = Mon through Fri | | */5 | Step | */5 * * * * = every 5 minutes | | 10-30/5 | Range + step | 10-30/5 * * * * = at minutes 10, 15, 20, 25, 30 |

Note: Day of week uses 0-7 where both 0 and 7 mean Sunday. You can also use three-letter names: SUN, MON, TUE, WED, THU, FRI, SAT.

Common Patterns You'll Actually Use

Here are the cron schedule examples that cover 90% of real-world needs:

Every 5 minutes:

*/5 * * * *

The step value */5 means "starting at 0, every 5th value." This fires at :00, :05, :10, :15, and so on.

Every weekday at 9:00 AM:

0 9 * * 1-5

Minute 0, hour 9, any day of month, any month, Monday through Friday.

First day of every month at midnight:

0 0 1 * *

Minute 0, hour 0, day 1, any month, any day of week.

Every Sunday at 2:30 AM:

30 2 * * 0

Every 15 minutes during business hours (9 AM to 5 PM), weekdays only:

*/15 9-17 * * 1-5

This fires at :00, :15, :30, :45 for each hour from 9 through 17, Monday to Friday. Note that it also fires at 17:15, 17:30, and 17:45—if you want to stop exactly at 17:00, you'd need two expressions or a more specific hour range like 9-16 plus 0 17 * * 1-5.

Every hour at the 30-minute mark:

30 * * * *

Twice daily (6 AM and 6 PM):

0 6,18 * * *

Every Monday, Wednesday, and Friday at 8 AM:

0 8 * * 1,3,5

The Day-of-Month vs Day-of-Week Gotcha

This is the one that bites people in production. When you specify both the day-of-month field and the day-of-week field (i.e., neither is *), cron uses OR logic, not AND.

0 0 1 * 5

You might read this as "run at midnight on the 1st of the month, but only if it's a Friday." That's wrong. It actually means: "run at midnight on the 1st of the month OR on any Friday."

This happens because the original Vixie cron implementation treats restricted day-of-month and day-of-week as independent triggers. If either condition is met, the job runs.

How to get AND behavior:

If you truly need "the 1st of the month, but only if it's a Friday," you have two options:

  1. Use one field and handle the other in your script:
0 0 1 * *

Then in your script:

#!/bin/bash
# Only proceed if today is Friday
if [ "$(date +%u)" -ne 5 ]; then
  exit 0
fi
# ... actual job logic
  1. Use a cron implementation that supports AND logic. Some modern schedulers (like Quartz in Java) handle this differently. Check your platform's documentation.

The safe rule: If you need day-of-week filtering, set day-of-month to *. If you need day-of-month filtering, set day-of-week to *. Avoid restricting both simultaneously unless you understand the OR behavior.

Step Values, Ranges, and Lists in Depth

Step values (/) define the interval between matches:

*/10 * * * *    → minutes 0, 10, 20, 30, 40, 50
0 */6 * * *     → hours 0, 6, 12, 18
0 0 */2 * *     → every 2nd day of the month (1st, 3rd, 5th...)

A common misconception: */5 in the minute field does NOT mean "every 5 minutes starting from when the job was created." It means "at minutes divisible by 5." If you create the job at 10:03, the next run is 10:05, not 10:08.

Ranges (-) define a continuous span:

0 9-17 * * *    → every hour from 9 AM to 5 PM (inclusive)
0 0 * * 1-5     → midnight, Monday through Friday
0 0 1-15 * *    → first 15 days of each month

Lists (,) combine discrete values:

0 8,12,18 * * * → 8 AM, noon, and 6 PM
0 0 1,15 * *    → 1st and 15th of each month

You can combine all three:

0,30 9-17/2 * * 1-5

This means: at minutes 0 and 30, for hours 9, 11, 13, 15, 17, Monday through Friday.

Real-World Examples: CI/CD and Backups

CI/CD: Run tests every 15 minutes during development hours:

*/15 8-20 * * 1-5

Fires every 15 minutes from 8 AM to 8 PM, weekdays. Your CI pipeline picks up new commits and runs the test suite without burning compute overnight.

Database backup: Daily at 2 AM:

0 2 * * *

Simple and reliable. Pair it with a retention script:

#!/bin/bash
pg_dump mydb | gzip > /backups/mydb_$(date +%Y%m%d).sql.gz
find /backups -name "*.sql.gz" -mtime +30 -delete

Weekly backup: Sunday at 3 AM:

0 3 * * 0

Monthly report generation: 1st of month at 6 AM:

0 6 1 * *

Certificate renewal check: Every day at 4 AM (for Let's Encrypt 90-day certs):

0 4 * * *

With a script that only renews if expiry is within 30 days:

#!/bin/bash
certbot renew --quiet --deploy-hook "systemctl reload nginx"

Log rotation: Every Sunday at 1 AM:

0 1 * * 0

Health check ping: Every minute (use sparingly):

* * * * *

Running every minute is fine for lightweight health checks, but avoid expensive operations at this frequency. If your job takes longer than 60 seconds, add a lock file to prevent overlapping runs:

#!/bin/bash
flock -n /tmp/myjob.lock -c 'actual_command_here'

Validating Your Cron Expressions

Cron syntax errors are silent—there's no compiler to catch them. A typo like 0 9 * * 8 (invalid day-of-week) might be rejected by some cron daemons or silently ignored by others.

Before deploying a schedule to production, validate it with a cron parser that shows you the next several run times in human-readable format. Seeing "Next runs: Mon Jul 28 at 9:00 AM, Tue Jul 29 at 9:00 AM..." makes it immediately obvious whether your expression does what you intended.

If you're building a schedule from a requirement ("I need this to run every 4 hours on weekdays"), a cron generator can produce the expression for you—then you can verify it with the parser to double-check the logic.

Both tools run entirely in your browser. No data is sent anywhere, which matters when you're encoding schedules that reveal internal infrastructure timing (backup windows, deployment schedules, maintenance periods).

Quick Reference Card

| Schedule | Expression | |----------|-----------| | Every minute | * * * * * | | Every 5 minutes | */5 * * * * | | Every hour | 0 * * * * | | Every day at midnight | 0 0 * * * | | Weekdays at 9 AM | 0 9 * * 1-5 | | Every Sunday at 2 AM | 0 2 * * 0 | | 1st of month at noon | 0 12 1 * * | | Every 6 hours | 0 */6 * * * | | Twice daily (8 AM, 8 PM) | 0 8,20 * * * | | Every 15 min, 9-5, weekdays | */15 9-17 * * 1-5 |

Bookmark this page, or better yet, keep a cron parser tab open whenever you're writing schedules. The five minutes you spend verifying an expression now saves the 3 AM page when a backup doesn't run.


ad