The Cron Expression Rules That Catch Everyone Out
Five fields, separated by spaces. Cron looks like the simplest configuration format in Unix — right up until your backup silently runs on the wrong days, your job fires twice one Sunday in autumn, or */7 turns out not to mean "every seven minutes" at all.
The short version
- When both day-of-month and day-of-week are restricted, cron runs when either matches — an OR, not an AND. This is the single most misread rule in cron.
*/7means "every 7th value in the range", so it resets at the range boundary and leaves an uneven gap.- Cron runs on the machine's local time. Daylight-saving transitions will skip a job or run it twice unless you schedule in UTC.
L,Wand#are Quartz extensions, not POSIX. They will not work in a standard crontab.
The five fields
A standard crontab entry is five whitespace-separated fields followed by a command:
┌─ minute 0-59
│ ┌─ hour 0-23
│ │ ┌─ day of month 1-31
│ │ │ ┌─ month 1-12 (or JAN-DEC)
│ │ │ │ ┌─ day of week 0-6 (or SUN-SAT; 7 also = Sunday)
│ │ │ │ │
* * * * * /usr/local/bin/backup.sh
Each field accepts four constructs, which combine freely:
| Syntax | Name | Example | Means |
|---|---|---|---|
* | Wildcard | * in hour | Every hour |
a-b | Range | 9-17 | 9 through 17 inclusive |
a,b,c | List | 0,15,30,45 | Those exact values |
*/n | Step | */15 | Every 15th value from the range start |
a-b/n | Stepped range | 9-17/2 | 9, 11, 13, 15, 17 |
The OR rule that breaks backups
Here is the rule most schedules get wrong, and it is specified behaviour rather than a bug. Consider:
0 0 13 * 5 # intent: "midnight on Friday the 13th"
That is not what it does. POSIX and Vixie cron both specify: if neither day-of-month nor day-of-week is *, the job runs when either field matches. So this expression fires at midnight on the 13th of every month and at midnight every Friday — roughly 64 times a year instead of the one or two you wanted.
The logic table is worth memorising:
| day-of-month | day-of-week | Combination |
|---|---|---|
* | restricted | Day-of-week only |
| restricted | * | Day-of-month only |
| restricted | restricted | OR — either one matching triggers the job |
* | * | Every day |
There is no cron syntax for a true AND. "The first Monday of the month" cannot be expressed in a standard five-field crontab. The conventional workaround is to schedule the superset and guard inside the job:
# Run every Monday, but exit unless it is also in the first 7 days
0 3 * * 1 [ "$(date +\%d)" -le 07 ] && /opt/bin/monthly-report.sh
Note the escaped \%. In a crontab, an unescaped % is special: it terminates the command and sends the remainder to the job's standard input. Forgetting the backslash is a classic way to get a job that appears to do nothing.
Why */7 is not "every seven minutes"
A step value does not mean "with this interval, continuously". It means "every nth value within this field's range, counting from the range's start". Because the minute field's range is 0–59 and 60 is not divisible by 7:
*/7 * * * *
→ 0, 7, 14, 21, 28, 35, 42, 49, 56, then 0 again
Gaps: 7 7 7 7 7 7 7 7 then 4 ← the boundary jump
Eight of the gaps are seven minutes and one is four. Any step that does not divide its range evenly produces this discontinuity — */7, */8, */9 and */45 on minutes; */5, */7 and */10 on hours. If you genuinely need an even cadence that is not a divisor of 60, cron is the wrong tool; use a timer that reschedules itself from completion, or a systemd timer with a monotonic OnUnitActiveSec.
Minutes: 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30. Hours: 1, 2, 3, 4, 6, 8, 12. Stick to these and every interval is uniform.
Daylight saving will bite you exactly twice a year
Classic cron evaluates against the system's local time. In a zone that observes daylight saving, two things happen annually:
- Spring forward. Local time jumps from 01:59 to 03:00. A job scheduled for 02:30 has no 02:30 to run at. Most implementations skip it entirely — so a monthly billing run scheduled at 02:30 quietly does not happen.
- Autumn back. 02:00 to 02:59 occurs twice. A job scheduled at 02:30 may run twice. If it is not idempotent, you have just double-charged customers or double-posted ledger entries.
Vixie cron has partial mitigations for jobs scheduled within the skipped window, but the behaviour differs between implementations and is not something to rely on. The robust answers, in order of preference:
- Run the host in UTC and do all timezone conversion inside the application. UTC has no DST, so every schedule is unambiguous.
- Set
CRON_TZ=UTCat the top of the crontab (supported by Vixie cron and its derivatives) to pin evaluation regardless of system zone. - Avoid 00:00–04:00 local for anything critical if you must run in local time. Nothing interesting happens at 05:30.
Note that GitHub Actions, AWS EventBridge and most managed schedulers evaluate cron in UTC already — which removes the DST problem but introduces a different one: your "9am daily" job drifts an hour relative to local business time twice a year.
Check what your expression really does
Layerbit's Cron Expression Generator translates any five-field expression into plain English and lists the next five run times in UTC, so the OR rule and step-boundary gaps become visible before you commit the schedule.
Extensions that are not in standard cron
A great deal of cron documentation on the web is actually Quartz documentation. Quartz is the Java scheduler used by Spring and many enterprise products, and it uses a similar-looking but genuinely different syntax. Pasting Quartz expressions into a Linux crontab fails, often silently.
| Syntax | Meaning | Works in POSIX cron? |
|---|---|---|
L | Last day of month / last given weekday | No — Quartz only |
W | Nearest weekday to a date | No — Quartz only |
# | Nth weekday of the month (6#3 = 3rd Friday) | No — Quartz only |
? | "No specific value" | No — Quartz only |
| 6 fields (seconds first) | Second-level precision | No — Quartz, Spring, some others |
@daily, @hourly, @reboot | Named shortcuts | Vixie extension — common on Linux, not POSIX |
Field counts are the fastest way to identify which dialect an expression came from. Five fields is classic cron. Six usually means seconds were prepended (Quartz, Spring) — but six can also mean a year field was appended, which is why you should never assume from count alone. Seven fields is Quartz with both seconds and year.
Cron will happily run your job on top of itself
Cron has no concept of a previous invocation. Schedule something */5 that occasionally takes seven minutes, and you will eventually have two copies running concurrently — competing for the same lock, the same temp file, or the same API rate limit. On a bad day the overlap compounds until the box falls over.
The standard defence is a lock acquired by the wrapper, not by your code:
# flock exits immediately if the lock is held, so overlapping runs are skipped
*/5 * * * * /usr/bin/flock -n /tmp/sync.lock /opt/bin/sync.sh
Use -n (fail immediately) rather than blocking, otherwise you build a queue of waiting processes instead of avoiding the problem. A lock file in /tmp is fine for a single host; for anything distributed you need a lease in a shared store with a TTL, so a dead worker cannot hold the lock forever.
The other reasons a cron job "does not run"
When a schedule is correct but nothing happens, it is almost always one of these:
- PATH. Cron runs with a minimal environment — typically
/usr/bin:/bin. Your shell's PATH is not inherited. Use absolute paths for every binary, or setPATH=at the top of the crontab. - No trailing newline. Older crons silently ignore the final line of a crontab if the file does not end with a newline.
- Unescaped
%. As above — it truncates your command. - Relative working directory. Jobs start in the user's home directory, not where the script lives. Anything relative resolves somewhere unexpected.
- Output going nowhere. Cron mails stdout and stderr to the user; if no mail transport is configured, the output is discarded. Redirect explicitly:
>> /var/log/myjob.log 2>&1.
That last one is the most important operationally. A cron job with no log is a job you cannot debug and cannot prove ran. Redirect output somewhere durable before you need it, not after.
Read the schedule out loud
Cron's real hazard is that an incorrect expression is still a valid expression. There is no syntax error to catch, no test that fails — just a job quietly running at the wrong times, sometimes for months, until someone notices the report covering the wrong window.
So before committing a schedule, do two things: translate it into a plain sentence, and look at its next several fire times. If the sentence does not match the intent, or the dates include days that surprise you, the expression is wrong regardless of how reasonable it looks.