Cron Expressions Explained: From */15 to Business-Hours-Only

A cron expression is a string of five space-separated fields β€” minute hour day-of-month month day-of-week β€” that tells the cron daemon exactly when to run a command. When you see */15 * * * *, it means "run every 15 minutes, every hour, every day, every month, regardless of the day of week." That's the short answer. But the moment you need something like "every 5 minutes during business hours on weekdays, but exclude holidays," things get interesting fast. This guide covers everything from decoding that first StackOverflow copy-paste to designing schedules that survive production.

The Five-Field Structure

Cron expressions use positional fields. Each field accepts a number, a range, a list, a step value, or the wildcard *. Order matters β€” a lot:

FieldAllowed ValuesSpecial Characters
Minute0–59* / , -
Hour0–23* / , -
Day of month1–31* / , - ? L W
Month1–12 (or JAN–DEC)* / , -
Day of week0–6 (0=Sunday) or 1–7* / , - ? L #

Note that L, W, #, and ? are extensions found in Quartz and Spring's CronExpression β€” they won't work in standard Unix cron. I'll stick to portable syntax here. Also worth knowing: the day-of-week numbering is configurable. On most Linux systems Sunday is 0, but some implementations treat 0 and 7 both as Sunday. If your Friday job runs on Thursday, check whether your cron flavor uses 0–6 or 1–7.

Special Characters, Decoded

One pattern that trips people up: */15 starts counting from 0, not from the current time. If you add a crontab entry at 10:07, the job won't run at 10:22 β€” it'll run at 10:15, 10:30, etc. Cron evaluates against the clock, not a relative offset.

Common Patterns Cookbook

Here are the expressions I've used most often across production systems. Copy these, but understand what each field is doing:

ScenarioExpressionWhat It Does
Every minute* * * * *Runs every single minute. Use sparingly β€” it's a lot of executions.
Every 15 minutes*/15 * * * *Runs at :00, :15, :30, :45. Good for health checks and metrics collection.
Every hour at :3030 * * * *Runs once per hour, offset from the top of the hour to avoid contention.
Daily at 2:00 AM0 2 * * *Runs every night at 2am. Classic for database backups and log rotation.
Weekdays at 9:00 AM0 9 * * 1-5Monday through Friday at 9am. Great for daily report generation.
Business hours, every 30 min*/30 9-17 * * 1-5Every 30 minutes, 9am–5pm, Monday–Friday. Ideal for order sync jobs.
First of every month0 0 1 * *Midnight on the 1st. Billing runs, monthly rollups.
Every 5 min, business hours*/5 9-17 * * 1-5Every 5 minutes during the workday. Useful for near-real-time processing.
Weekdays at 6:00 PM0 18 * * 1-5End-of-day processing, just as the team is heading out.
Every Sunday at 3:00 AM0 3 * * 0Weekly cleanup, full backups, or maintenance windows.

The Last Day of the Month Problem

There's no portable way to express "last day of the month" in standard cron. Some systems support the L modifier (Quartz, Spring), but in Unix cron you need a workaround. The common approach: run the job on the 28th–31st and check the date inside the script:

# crontab: run at 11:55pm on days 28-31
55 23 28-31 * * [ "$(date -d tomorrow +\%d)" = "01" ] && /path/to/script.sh

This runs the script only if tomorrow is the 1st β€” meaning today is the last day of the month. Clunky but reliable. I've seen this pattern in production at three different companies.

Timezone and DST: The Silent Killers

Cron runs in the server's local timezone β€” not UTC β€” unless you've explicitly configured it otherwise. This is the single biggest source of "my cron job didn't run" tickets I've debugged. If your server is set to America/New_York and you deploy from a UTC pipeline, what you think is 2am UTC might be 10pm the previous day.

Daylight Saving Time: Two Traps

Spring forward (March): Clocks jump from 2:00am to 3:00am. If you have a job scheduled for 2:30am, it will not run on that day β€” 2:30am simply doesn't exist. One missed execution is usually tolerable, but if you're running financial reconciliation that must happen, you need a strategy.

Fall back (November): Clocks fall from 2:00am back to 1:00am. A job at 1:30am runs twice β€” once during the first 1:30am, then again an hour later when 1:30am occurs a second time. If your job isn't idempotent, you'll double-bill customers or insert duplicate rows.

How We Handle It

Cron vs. systemd Timers

If you're on a modern Linux distro (systemd has been the default since ~2015), you have two scheduling systems available. Here's when I reach for each:

FeatureCronsystemd Timer
Syntax5-field expressionOnCalendar= with systemd.time(7) format
Timezone supportServer-local onlyExplicit with OnCalendar=*-*-* 02:00:00 America/New_York
Randomized delayNot built in (use sleep $((RANDOM % 60)))RandomizedDelaySec=120 β€” stagger jobs across machines
Missed job catch-upNo (if server was down, job is lost)Persistent=true β€” runs missed jobs on next boot
DependenciesNoneCan require other units (network, mount, service) before firing
Loggingsyslog or emailjournald β€” rich, queryable, structured
PortabilityEvery Unix-like systemLinux with systemd only

Here's what the "weekdays at 9am" pattern looks like as a systemd timer:

# /etc/systemd/system/daily-report.timer
[Timer]
OnCalendar=Mon..Fri *-*-* 09:00:00
RandomizedDelaySec=300
Persistent=true

[Install]
WantedBy=timers.target

I still use cron for simple tasks on servers I know won't reboot often. But for anything that ships to customers β€” especially if missed runs matter β€” I default to systemd timers. The combination of Persistent=true and RandomizedDelaySec solves two of cron's biggest weaknesses in one file.

Enterprise Scheduling Gotchas

After a decade of debugging cron in production, here's what actually breaks:

1. Long-Running Jobs Stacking Up

If your job takes 7 minutes but runs every 5 minutes, you'll have overlapping instances. Cron doesn't care β€” it fires on schedule regardless. Fix this with flock:

*/5 * * * * flock -n /tmp/myjob.lock /usr/local/bin/my_script.sh

The -n flag means "non-blocking" β€” if the lock is held, exit immediately instead of waiting. This prevents job pile-ups. If you need to know when a run was skipped, log the exit:

*/5 * * * * flock -n /tmp/myjob.lock /usr/local/bin/my_script.sh || logger "my_script skipped β€” previous run still active"

2. Cron Doesn't Retry

If your job fails at 2am, cron doesn't try again at 2:05am. It waits until the next scheduled time. For critical jobs, you have three options: (a) build retry logic into the script itself, (b) switch to a proper job scheduler like Airflow or Temporal, or (c) use systemd timers with OnUnitActiveSec= for periodic retries.

3. The Minimal Cron Environment

Cron doesn't source your .bashrc, .profile, or any user environment. The PATH inside a cron job is typically just /usr/bin:/bin. I've lost count of how many times I've debugged a "command not found" error that worked perfectly in my shell. Always use absolute paths:

# Wrong β€” $PATH might not contain /usr/local/bin
0 2 * * * backup.sh

# Right
0 2 * * * /usr/local/bin/backup.sh --config /etc/backup.conf

You can set PATH at the top of your crontab file:

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SHELL=/bin/bash
0 2 * * * backup.sh

4. Output Goes... Somewhere

Cron emails all output (stdout + stderr) to the crontab owner's local mailbox by default. On most cloud servers, local mail delivery isn't configured β€” so the output vanishes into a black hole. Redirect explicitly:

0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

Or use logger to pipe into syslog/journald:

0 2 * * * /usr/local/bin/backup.sh 2>&1 | logger -t backup-cron

5. Testing Cron Expressions

Don't guess whether */15 9-17 * * 1-5 does what you think. Test it. Our Cron Expression Builder shows a visual timeline of upcoming executions and a human-readable description β€” it's caught mistakes I'd have otherwise shipped to production. If you're correlating cron schedules with external timestamps, the Unix Timestamp Converter helps bridge the gap between cron times and epoch seconds.

When Cron Is the Wrong Tool

Cron is a time-based scheduler. It excels at "run this at 2am every day." It's the wrong tool for:

Quick Reference: Decode Any Cron Expression

When you're staring at an unfamiliar cron expression, decode it field by field, left to right:

  1. Minute β€” when in the hour does it fire?
  2. Hour β€” which hours of the day?
  3. Day of month β€” which days? (1–31)
  4. Month β€” which months? (1–12)
  5. Day of week β€” which days? (0–6, Sunday=0)

Remember the AND/OR logic: day-of-month and day-of-week are effectively OR'd in most implementations. If either matches, the job runs. This means 0 0 1 * 0 runs at midnight on the 1st of the month and every Sunday β€” not just Sundays that happen to fall on the 1st. This behavior surprises people constantly.

Cron expressions look cryptic until you internalize the field order and special characters. Once you do, they're one of the most reliable ways to schedule work on a Unix system. But know the traps β€” timezones, DST, overlapping runs, and missing environments β€” and you'll save yourself the 2am debugging sessions that taught me these lessons the hard way.