How to read a cron expression: day and weekday are ORed, and when the next run really is
A crontab line has five fields, and it still generates an outsized share of operations tickets: the job that did not run, the job that ran all day, the “every Friday the 13th” nobody can write. Almost all of it comes down to three things — the order of the fields, the rule for combining day and weekday, and which clock “next run” is computed against.
The order you read is the order they run
minute hour day-of-month month day-of-week
0-59 0-23 1-31 1-12 0-7 (both 0 and 7 are Sunday)
The first field is the minute, not the hour — most of the “I meant 1 a.m. daily and it runs 60 times a day” reports are exactly that mistake. Each field accepts a single value, a list 1,15,30, a range 1-5, a step */10 or 0-59/5; month and weekday also take three-letter names JAN…DEC and SUN…SAT. Beyond that, the only legal content left is aliases (@daily, @weekly, @hourly, @reboot) and typos.
Six fields means a seconds column at the front, seven means a year column at the end. Neither exists in Vixie cron or in Linux /etc/crontab; they belong to the Quartz / Spring @Scheduled / EventBridge family of dialects. Same day you may meet L (last day of month), W (nearest weekday), # (third Friday is 5#3), and those do not mean the same thing in every implementation. Decide which dialect you are looking at before you try to explain the line.
1. When both day fields are restricted, it is OR
This is the least intuitive rule in cron: when both date fields are restricted, the job runs if either one matches. It is a union, not an intersection.
0 0 13 * 5 # the 13th of every month, plus every Friday
It reads like “Friday the 13th”, but the schedule computed from this machine’s calendar runs 2026-10-09 (Fri), 10-13 (Tue), 10-16 (Fri), 10-23, 10-30, 11-06, 11-13 (a day that is both) — the union of the two sets. Count the next 366 days: 52 Fridays + twelve 13ths − 2 overlapping days = 62 runs, not “roughly twice a year”.
Now the converse: if either field is * (or ?), the other one decides on its own. Hence a purely logical oddity — in 0 0 13 * 0-6 the weekday field is written 0–6, which is the whole week but still counts as “restricted”, so the union covers every day and it becomes a daily job. And a real “Friday the 13th” cannot fit in one crontab line: you schedule 0 0 * * 5 and put if [ "$(date +\%d)" = 13 ] inside the command.
2. */5 aligns to midnight, not to your last run
A step of */5 in the hour field gives 00:00, 05:00, 10:00, 15:00, 20:00. The step is produced by comparing the current clock against each field. cron has no memory of when it last fired, so a run that overran is not accounted for, a missed tick is not caught up, and there is no notion of “last success” at all. For relative pacing like “every 5 hours from now”, use a systemd timer with OnUnitActiveSec= or an external scheduler.
Same rule, different symptom: 0 0 30 * * behaves like “skips months shorter than 30 days”, because it matches the 30th day of each month and February has no such day, so February is simply absent. Five-field cron has no portable way to say “last day of the month” (L is a vendor extension); the usual compromise is 0 0 28-31 * * plus a check for “is tomorrow the 1st”.
3. 0 0 29 2 * waits four years
Counting forward from 2026-10-04 on this machine’s calendar: the next occurrence of 0 0 29 2 * is 2028-02-29, and the one after that is 2032-02-29. 2029, 2030 and 2031 have no 29 February. Push the expression somewhere more awkward and it can hit 2100, which is not a leap year (divisible by 100 but not by 400) — February 2100 has 28 days, so that date waits until 2104. Any “next run” table has to say which calendar and which timezone it was computed on. To check how many days are left until one of those dates, run it through the date difference calculator rather than counting leap years in your head.
4. The DST hour: the calendar decides whether it exists, the implementation decides how many times you run
Measured with an ICU calendar — not read out of documentation, but by counting how many instants the same wall-clock minute maps to:
- America/New_York, 2026-03-08 (Sunday): local 02:30 corresponds to 0 instants — the clock jumps from 01:59 to 03:00. So
30 2 * * *has no matching moment that day and moves to the next. - Same zone, 2026-11-01 (Sunday): local 01:30 corresponds to 2 instants (05:30Z and 06:30Z) — the autumn fallback makes that hour happen twice.
- Asia/Shanghai returns exactly 1 instant for both probes: China has not observed DST since 1991, Beijing time is a constant GMT+8, so a
30 2 * * *on a Beijing-time server runs 365 times a year (366 in a leap year) and never has to answer “skipped or doubled?”.
The calendar only tells you that the hour does not exist, or exists twice. Whether your cron fires 0, 1 or 2 times that day is the implementation’s own policy — Vixie and cronie each describe it differently in their crontab(5), and the authority is the manual page on that machine, not a web page or an online parser. A cross-timezone team that agreed on “the report at 2 a.m. daily” will find it wrong on some days if that machine lives in a DST zone; write such schedules as UTC instants, or use a scheduler with an explicit timezone field (cronie-style implementations accept CRON_TZ= inside a crontab; the cron shipped on Debian does not document it, so do not assume it).
5. Checklist for “the line is correct but it never ran”, by hit rate
- Timezone. The server clock is not your clock — this is the single most common cause of “it already ran, and it says it has three hours to go”. Run
dateon the server first, then re-read the expression against it. - CRLF. A crontab edited in a Windows editor ends every line with
\r; at best a command gets an invisible extra character, at worst the whole line is rejected. Run it throughsed -i 's/\r$//'ordos2unix. - Unescaped
%. In the Vixie/cronie family,%in the command is translated to a newline and everything after the first one becomes the command’s standard input.date +%Fhas to be writtendate +\%F. Nearly everyone eats this one once. - PATH and relative paths. cron hands you a minimal shell:
~expansion and your user PATH entries may not be there. Use absolute paths for the command, andcdat the top of the script. - It was never loaded.
crontab -eorcrontab fileis what installs a table; editing the file under/var/spool/cron/...directly does not get picked up by most implementations./etc/cron.d/files must have no:or.in the name (extension included) and carry one extra field: the user. - The command itself failed. No parser can see this. Redirect output to a log (
>> /var/log/job.log 2>&1) and read it — mail notification is not configured on most minimal systems.
6. When cron is the wrong tool
If you need “catch up when we missed a run”, that is a systemd timer with Persistent=true or an external scheduler. Same for “Nth business day” or “X seconds since the last activation” — cron’s whole model is comparing the current clock against the fields. Cross-machine dependencies (“run B after A finishes”) should not be built out of aligned timestamps.
Worth running the line through the online cron parser once: it expands every field into the values it actually matches, says whether it read the line as 5, 6 or 7 fields, and computes the next 8 real instants using this machine’s calendar — including which timezone it used and a note when the expression matches nothing in shorter months. For vendor extensions (L, W, #) and a step of 0, it reports the token it could not read rather than inventing a meaning, which is the attitude your crontab should have too.
Related questions
Are 0 0 * * 0 and 0 0 * * 7 the same? Yes — 0 and 7 are both Sunday. What day is @weekly? Sunday in the implementations you will meet, but aliases are not standard, so don’t bet on it.
Does my expression get sent anywhere? No. Parsing is string splitting in the script itself and the instants come from the browser’s calendar. Nothing is handed to a server, and what you type never leaves this device.
Why do “daily at 00:00” and “minute 0 of every hour” look alike but fire differently? 0 0 * * * runs once a day; 0 * * * * runs 24 times. The first field is minutes and the second is hours — replacing that second 0 with * turns “at midnight” into “every hour”.