Web Development

How Do I Create Cron Expressions for Business Hours and Complex Schedules?

Ready-to-copy cron expressions for business hours, every-15-minutes-during-hours, first Monday, quarterly, and seasonal schedules — plus the day-of-month/day-of-week OR gotcha that silently breaks 'first Monday' style rules.

By Inventive HQ Team

To schedule a cron job for business hours, use 0 9-17 * * 1-5 — it runs at the top of every hour from 9 AM to 5 PM, Monday through Friday. The five fields are minute, hour, day-of-month, month, and day-of-week: 9-17 is a range of hours (17 is 5 PM), 1-5 is Monday through Friday, and the 0 in the minute field pins each run to the top of the hour. From that one pattern you can derive almost every business schedule — add a step (*/15 9-17 * * 1-5 for every 15 minutes during hours), split into two entries to skip lunch, or list specific months for quarterly and seasonal jobs.

That's the summary an AI overview gives you. What it leaves out is the trap that silently breaks the schedules people most often want: when you set both the day-of-month and day-of-week fields, standard cron runs the job when either one matches — not both. That single rule is why 0 9 1-7 * 1 does not mean "first Monday of the month," and why "last Friday" and "first business day" cannot be written in cron at all. This guide gives you copy-ready expressions for the common cases and shows exactly where cron runs out of road.

The business-hours cron, visualized

A weekly business-hours cron schedule Seven day columns Monday through Sunday. Monday to Friday show a highlighted 9 AM to 5 PM band with firing markers every two hours; Saturday and Sunday are dimmed and idle. A horizontal time line sweeps down the day and back. 0 9-17/2 * * 1-5 — every 2 hours, 9–5, weekdays 9 AM 1 PM 5 PM Mon Tue Wed Thu Fri Sat Sun now

Every business schedule is a variation on that picture: pick the hours (a range like 9-17), the frequency within the hour (a step like */15 or a minute list), and the days (a range or list like 1-5). Master those three levers and you rarely need anything more exotic.

Loading interactive tool...
Advertisement

Business-hours pattern reference

Copy these directly. All are standard five-field expressions — minute hour day-of-month month day-of-week — and assume Monday=1 through Friday=5.

What you wantCron expressionHow it reads
Every hour, 9 AM–5 PM, weekdays0 9-17 * * 1-5Minute 0 of hours 9 through 17, Mon–Fri (9 runs/day)
Once at 9 AM, weekdays0 9 * * 1-59:00 AM Monday through Friday
Every 15 min during business hours*/15 9-17 * * 1-5Minutes 0/15/30/45 of hours 9–17, Mon–Fri
Every 30 min during business hours0,30 9-17 * * 1-5Top and bottom of each hour, 9–17, Mon–Fri
Every 5 min during business hours*/5 9-17 * * 1-512 runs/hour across the 9–5 window, weekdays
Every 2 hours during business hours0 9-17/2 * * 1-5Fires at 9, 11, 13, 15, 17, Mon–Fri
Morning + afternoon, skip lunch0 9-11 * * 1-5 and 0 13-17 * * 1-5Two entries; noon hour is skipped
Weekdays and Saturday0 9 * * 1-69 AM Mon–Sat (excludes Sunday)
Mon / Wed / Fri only0 10 * * 1,3,510 AM on Monday, Wednesday, Friday
Twice a day, specific days0 6,18 * * 1,46 AM and 6 PM on Monday and Thursday
Start and end of business day0 9,17 * * 1-59 AM and 5 PM, Mon–Fri (open/close jobs)

How the three fields combine

The minute field controls frequency inside the hour, the hour field defines the window, and the day-of-week field picks the days. */15 9-17 * * 1-5 is just "every 15 minutes" (*/15) applied within "9 AM to 5 PM" (9-17) on "weekdays" (1-5). Change one lever at a time and the expression stays readable.

Watch the step-on-a-range rule. A step attaches to whatever precedes it. 9-17/2 means "every 2nd hour of the range 9–17" → 9, 11, 13, 15, 17. Writing 0 */2 9-17 * * 1-5 is a six-field expression and is invalid in standard five-field cron — the range must go in the hour field, not a separate one. If you truly want strict two-hour steps, 0 9-17/2 * * 1-5 is correct.

Seasonal and calendar schedules

The month field (4th) takes ranges and lists, so seasonal jobs are easy.

ScheduleCron expressionNotes
Every day in winter (Dec–Feb) at 2 AM0 2 * 12,1,2 *Month list wraps the calendar year
Every day in summer (Jun–Aug) at 6 AM0 6 * 6-8 *Month range
Quarterly, 1st of Jan/Apr/Jul/Oct, 9 AM0 9 1 1,4,7,10 *Calendar quarters
Quarterly, 1st of Mar/Jun/Sep/Dec, 9 AM0 9 1 3,6,9,12 *Fiscal-style quarters
1st of every month at 3 AM0 3 1 * *Monthly rollup
Every 6 hours, all day0 */6 * * *Midnight, 6 AM, noon, 6 PM

The one gotcha that breaks "first Monday" and "last Friday"

Here is the rule almost every cron tutorial glosses over, and it is responsible for most "why did my job run on the wrong day" incidents:

When both the day-of-month (field 3) and day-of-week (field 5) are set to something other than *, standard cron runs the job when either field matches — not both.

This is documented behavior in Vixie/ISC cron and POSIX crontab. People expect AND; cron does OR. The consequences:

  • 0 0 13 * 5 does not mean "Friday the 13th." It runs on the 13th of every month AND on every Friday.
  • 0 9 1-7 * 1 does not mean "first Monday." It runs on days 1–7 of every month AND on every Monday all month long.
  • 0 9 1 * 1-5 does not mean "first business day." It runs on the 1st AND on every weekday — essentially every weekday plus the 1st.

To get true "AND" behavior, restrict one field in cron and enforce the other inside the script:

# First Monday of the month at 9 AM — CORRECT approach
# crontab: run every day in the first week, guard for Monday
0 9 1-7 * * /opt/jobs/first-monday.sh
#!/bin/bash
# /opt/jobs/first-monday.sh
# date +%u gives 1=Mon .. 7=Sun. Exit unless today is Monday.
[ "$(date +%u)" = "1" ] || exit 0
/opt/jobs/monthly-report.sh

The same pattern handles last Friday of the month — a schedule cron genuinely cannot express, because it depends on the number of days in the month:

# crontab: check every Friday
0 10 * * 5 /opt/jobs/last-friday.sh
#!/bin/bash
# /opt/jobs/last-friday.sh
# If adding 7 days rolls into next month, this is the last Friday.
[ "$(date -d '+7 days' +%m)" != "$(date +%m)" ] || exit 0
/opt/jobs/month-end-report.sh

On macOS/BSD date, use date -v+7d +%m instead of date -d '+7 days' +%m.

Excluding dates: cron has no NOT

Cron cannot say "every day except December 25." There is no negation operator, so you guard inside the command. Remember that a literal % in a crontab line must be escaped as \%:

# Run the backup at 2 AM daily, except on December 25
0 2 * * * [ "$(date +\%m\%d)" != "1225" ] && /opt/backup.sh

For a list of blackout dates (holidays, freeze windows), keep them in a file and let the script check membership — far more maintainable than stacking cron entries.

Combining schedules with multiple entries

Cron's real power for "complex" needs is just running several simple entries. A tiered backup strategy:

# Hourly backup during business hours
0 9-17 * * 1-5 /opt/backup/hourly.sh

# Lighter backup outside business hours (4x/day)
0 0,6,20,22 * * * /opt/backup/frequent.sh

# Full backup on weekends at 2 AM (Sun=0, Sat=6)
0 2 * * 0,6 /opt/backup/full.sh

Two habits keep multi-entry schedules sane: comment every line with the human-readable intent, and stagger start times so heavy jobs don't collide (run the DB optimize at 4 AM after the 2 AM backup finishes, not at the same instant).

When to stop using cron

Cron is the right tool for anything expressible in five fields: fixed times, ranges, steps, and lists. Reach for application logic or a managed scheduler when you need:

  • "Last Friday," "last business day," or holiday-aware rules (cron can't; a script guard or scheduler can).
  • Time-zone and DST correctness — cron runs in the host's local time and mishandles the spring-forward/fall-back hours. Run jobs in UTC and convert in code, or use a scheduler that understands zones. See our guide on handling time zones and DST in cron.
  • Conditional execution, retries, backoff, and observability — cron just fires; it doesn't know if the last run failed.

Good options: APScheduler (Python), node-cron / Agenda (Node.js), Celery beat (distributed Python), AWS EventBridge Scheduler, and Google Cloud Scheduler. Most accept cron syntax for the simple part and add the capabilities cron lacks.

Validate before you deploy

Complex expressions are easy to get subtly wrong — an off-by-one hour or the OR gotcha above. Before shipping:

  1. Preview the fire times in our cron expression builder or crontab.guru — confirm it matches your intent for the next several runs.
  2. Dry-run by scheduling a copy two minutes out and watching it execute.
  3. Check the logs (/var/log/cron, /var/log/syslog, or journalctl -u cron) to confirm real execution and catch %-escaping mistakes.
  4. Comment the schedule in the crontab so the next person (or you, in six months) knows what it's for.

For the underlying field-by-field syntax, start with What is a cron expression?; for wiring schedules into code, see using cron expressions in applications.

Frequently Asked Questions

What is the cron expression for business hours (9 to 5, weekdays)?

Use 0 9-17 * * 1-5 to run at the top of every hour from 9 AM to 5 PM, Monday through Friday. The range 9-17 covers the hours (17 is 5 PM in 24-hour time), and 1-5 is Monday through Friday. Note this fires at 9:00 and again at 17:00, so it runs nine times a day. If you want it to stop at 4 PM, use 9-16; if you want the top and bottom of each hour, use 0,30 9-17 * * 1-5.

How do I run a cron job every 15 minutes during business hours?

Use */15 9-17 * * 1-5. The */15 in the minute field means minutes 0, 15, 30, and 45; the 9-17 limits it to business hours; and 1-5 limits it to weekdays. That produces four runs per hour across the 9-to-5 window on Monday through Friday. Swap */15 for */5, */10, or */30 to change the frequency.

How do I schedule a cron job for the first Monday of every month?

Cron cannot express 'first Monday' directly, and the common attempt 0 9 1-7 * 1 does NOT work — because when both the day-of-month and day-of-week fields are restricted, standard (Vixie) cron runs the job when EITHER matches, not both. So 0 9 1-7 * 1 fires on days 1 through 7 AND on every Monday all month. The reliable pattern is to schedule 0 9 1-7 * * and add a guard in the script: [ "$(date +%u)" = "1" ] || exit 0. That runs the script on the first seven days but only proceeds on the one that is a Monday.

Why does my cron job with both a day-of-month and a weekday run too often?

Because of the day-of-month / day-of-week OR rule. In standard cron, if both the 3rd field (day of month) and the 5th field (day of week) are set to anything other than *, the job runs whenever EITHER condition is true. Many people expect AND. So 0 0 13 * 5 runs on the 13th of the month and on every Friday, not only on Friday the 13th. To get AND behavior, restrict one field in cron and enforce the other with a date check inside the script.

How do I exclude lunch time or specific dates from a cron schedule?

Cron has no NOT operator, so you handle exclusions with either multiple entries or a script guard. For a lunch gap, use two entries — 0 9-11 * * 1-5 and 0 13-17 * * 1-5 — to skip noon. To skip a specific date such as December 25, wrap the command in a check: 0 2 * * * [ "$(date +%m%d)" != "1225" ] && /backup.sh. Note that percent signs must be escaped as % inside a crontab line.

What is the cron expression for quarterly jobs?

Use 0 9 1 1,4,7,10 * to run at 9 AM on the first day of January, April, July, and October — a calendar-quarter schedule. If your quarters end in March, June, September, and December, use 0 9 1 3,6,9,12 * instead. The month field accepts a comma-separated list, and 1 in the day-of-month field pins it to the first of each listed month.

How do I run something every two hours during business hours?

Use 0 9-17/2 * * 1-5. The step /2 applied to the range 9-17 selects every second hour starting at 9 — so it fires at 9, 11, 13, 15, and 17. Do not write 0 */2 9-17 * * 1-5; that has six fields and is invalid in standard five-field cron. Standard cron is minute, hour, day-of-month, month, day-of-week — the range belongs in the hour (second) field.

Can cron handle time zones and daylight saving time?

Cron runs in the system's local time zone (or the CRON_TZ / TZ setting where supported), and it does not gracefully handle daylight-saving transitions. On the spring-forward day a job scheduled for 2:30 AM may be skipped, and on fall-back it may run twice. For anything time-zone-sensitive, run the job in UTC and convert inside the script, or use an application scheduler such as APScheduler or node-cron that understands time zones and DST.

Should complex schedules live in cron or in application code?

Use plain cron for anything expressible in the five fields: fixed times, ranges, steps, and lists. Move to application logic or a managed scheduler when you need 'last Friday of the month', holiday awareness, conditional execution, retries, or reliable time-zone handling. Tools like APScheduler (Python), node-cron and Agenda (Node.js), Celery beat, AWS EventBridge, and Google Cloud Scheduler give you those capabilities without brittle multi-entry workarounds.

cronschedulingcomplex expressionsbusiness logic