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:
| Field | Allowed Values | Special Characters |
|---|---|---|
| Minute | 0β59 | * / , - |
| Hour | 0β23 | * / , - |
| Day of month | 1β31 | * / , - ? L W |
| Month | 1β12 (or JANβDEC) | * / , - |
| Day of week | 0β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
*(wildcard): "Every" β match all legal values for that field.* * * * *= every single minute./(step): "Every N" β*/15in the minute field means "every 15 minutes" (0, 15, 30, 45). It's a shortcut for0,15,30,45. The two are equivalent, but*/15is clearer when the step is the point.,(list): "At these specific values" β0,30means "at minute 0 and minute 30."-(range): "Through" β9-17in the hour field means "from 9am through 5pm inclusive."
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:
| Scenario | Expression | What 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 :30 | 30 * * * * | Runs once per hour, offset from the top of the hour to avoid contention. |
| Daily at 2:00 AM | 0 2 * * * | Runs every night at 2am. Classic for database backups and log rotation. |
| Weekdays at 9:00 AM | 0 9 * * 1-5 | Monday through Friday at 9am. Great for daily report generation. |
| Business hours, every 30 min | */30 9-17 * * 1-5 | Every 30 minutes, 9amβ5pm, MondayβFriday. Ideal for order sync jobs. |
| First of every month | 0 0 1 * * | Midnight on the 1st. Billing runs, monthly rollups. |
| Every 5 min, business hours | */5 9-17 * * 1-5 | Every 5 minutes during the workday. Useful for near-real-time processing. |
| Weekdays at 6:00 PM | 0 18 * * 1-5 | End-of-day processing, just as the team is heading out. |
| Every Sunday at 3:00 AM | 0 3 * * 0 | Weekly 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
- Run servers in UTC. No DST, no surprises. This is the simplest fix and what I recommend for most deployments. All cron times become UTC, and you handle timezone conversion at the application layer.
- Avoid the danger zone. Schedule critical jobs at times DST transitions never hit β 3:30am instead of 2:30am, or 12:30pm instead of 1:30am.
- Use systemd timers. They have explicit timezone support via
OnCalendar=and handle DST correctly (see next section). - Make jobs idempotent. If your job can safely run twice, the fall-back double-fire stops being a bug. Use idempotency keys, upserts, or pre-flight checks.
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:
| Feature | Cron | systemd Timer |
|---|---|---|
| Syntax | 5-field expression | OnCalendar= with systemd.time(7) format |
| Timezone support | Server-local only | Explicit with OnCalendar=*-*-* 02:00:00 America/New_York |
| Randomized delay | Not built in (use sleep $((RANDOM % 60))) | RandomizedDelaySec=120 β stagger jobs across machines |
| Missed job catch-up | No (if server was down, job is lost) | Persistent=true β runs missed jobs on next boot |
| Dependencies | None | Can require other units (network, mount, service) before firing |
| Logging | syslog or email | journald β rich, queryable, structured |
| Portability | Every Unix-like system | Linux 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:
- Event-driven workflows: "Run when a file appears" β use
inotifywaitor a message queue. - Complex DAGs: "Run task B after task A finishes, but only if task C succeeded" β use Airflow, Prefect, or Dagster.
- Sub-minute scheduling: Cron's minimum granularity is one minute. For per-second scheduling, use a long-running daemon or systemd timer with
AccuracySec=1s. - Distributed coordination: "Only one instance across the cluster should run" β use a distributed lock (etcd, Consul, Redis Redlock) on top of cron, or reach for Kubernetes CronJobs with leader election.
Quick Reference: Decode Any Cron Expression
When you're staring at an unfamiliar cron expression, decode it field by field, left to right:
- Minute β when in the hour does it fire?
- Hour β which hours of the day?
- Day of month β which days? (1β31)
- Month β which months? (1β12)
- 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.