Skip to content
Utilities2026-07-139 min read

Cron Expressions Explained: From '* * * * *' to Complex Schedules

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. Try any expression live with our free Cron Parser as you read.

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:

SyntaxMeaningExample
*Every value* * * * * = every minute
5Specific value5 * * * * = at minute 5
1,15,30List0 9,17 * * * = 9 AM and 5 PM
1-5Range0 9 * * 1-5 = Mon through Fri
*/5Step*/5 * * * * = every 5 minutes
10-30/5Range + step10-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. Months accept JAN–DEC the same way. The Cron Parser expands these aliases to numbers and normalizes 7 to Sunday 0.

Name aliases: readable to humans, accepted by machines

People often copy 0 9 * * MON-FRI from docs and paste it into a digits-only validator that rejects it. Vixie crontab and most Linux distros accept three-letter English names (case-insensitive):

FieldAlias exampleNumeric equivalent
MonthJAN,JUN / JAN-MAR1,6 / 1-3
WeekdaySUN / MON-FRI0 / 1-5

Aliases are pure sugar — same semantics as digits. When half the team writes numbers and half writes names, expand both in the parser and compare value lists instead of arguing whether Monday is 0 or 1.

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, use the cron parser for a three-step check:

  1. Field expansion — confirm each field lands on the digits you meant (no accidental */50).
  2. Readable summary — English phrase assembly (Month / Day / Weekday / Hour / Minute) for a quick scan of the value sets.
  3. Next 5 runs — wall-clock previews in the browser local timezone, with Vixie day/weekday OR semantics. For 0 0 1 * 5 you will see both “1st of month at midnight” and “every Friday at midnight” — if you wanted AND, it fails here on purpose.

Timezone caveat: “next runs” use the clock of the machine viewing the page, not the target server’s TZ. Cloud VMs are often UTC; containers without TZ usually are too. Convert the preview to the server timezone, or drop a low-frequency probe that appends date to a log on the box, then keep the schedule once both sides agree.

If you're building from a requirement (“every 4 hours on weekdays”), the cron generator can point-and-click the expression and show the same next-run preview — then paste back into the parser for a second look.

Both tools run entirely in the browser. No expression is uploaded, which matters when schedules encode backup windows, deploy cadence, or maintenance slots.

Beyond five fields: macros, seconds, and dialects

Standard crontab also has macros (@hourly, @daily, @weekly, @monthly, @yearly, @reboot) with fixed meaning — they never go through five-field parsing. Kubernetes CronJob and most SaaS schedulers accept only five fields, not macros; pasting @daily into a CronJob YAML fails outright.

The reverse trap is one field too many: Quartz / Spring @Scheduled default to a seconds field (six or seven fields). Paste Linux 0 2 * * * into Quartz and the whole string shifts right — “never fires” or “fires every second.” Count fields before migrating: this site’s parser and generator bind classic five-field cron; six fields return INVALID_CRON_FORMAT.

Quick Reference Card

ScheduleExpression
Every minute* * * * *
Every 5 minutes*/5 * * * *
Every hour0 * * * *
Every day at midnight0 0 * * *
Weekdays at 9 AM0 9 * * 1-5
Every Sunday at 2 AM0 2 * * 0
1st of month at noon0 12 1 * *
Every 6 hours0 */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.

Triage notes

The whole article is built around one trap: when both day-of-month and day-of-week are restricted, cron ORs them instead of ANDing — the intro warns that nearly everyone has been bitten. The safety rule is deliberately blunt: if you need weekday filtering, set the day field to *, and don't restrict both fields unless you clearly understand the OR behavior. The verification section exists because cron syntax errors are silent — no compiler catches them for you — and the "next 5 runs" step intentionally computes under OR semantics: 0 0 1 * 5 lists both the 1st of each month and every Friday, so an AND intent exposes itself on the spot. The dialect section's six pitfalls are equally typical: pasting a five-field expression into Quartz shifts the whole string, showing up as "never fires" or "fires every second".