Crontab, Properly Explained
Paul Vixie's cron from 1987 set the conventions every modern scheduler still inherits β five fields, asterisks for any-value, ranges and lists, the @daily macros. Cron is older than the web, older than Linux, almost as old as me, and the small ways its semantics surprise people are responsible for an outsized share of production incidents in scheduled jobs.
What it actually is
A crontab line has five whitespace-separated time fields followed by a command:
minute hour day-of-month month day-of-week command
0 9 * * 1-5 /usr/local/bin/run_report
That line means: at minute 0 of hour 9, on every day of every month, when the day-of-week is Monday through Friday, run the report.
Each field accepts:
- A number β
9means hour 9. - A range β
1-5means values 1 through 5. - A list β
1,3,5means values 1, 3, and 5. - A wildcard β
*means every valid value for that field. - A step β
*/15means "every 15,"0-30/5means "0, 5, 10, ..., 30."
Day-of-week is 0β6 with both 0 and 7 meaning Sunday. (Yes, both. It's an old wart.) Some implementations also accept three-letter names: MON, TUE, etc.
That's the entire syntax for vanilla cron. Any expression you can't construct from those rules is using an extension.
The day-of-month / day-of-week trap
The single most counterintuitive piece of cron semantics:
0 9 1 * 1 /run.sh
You'd think this means "9am on the 1st of the month, but only if it's a Monday." It doesn't. It means "9am on the 1st of the month, OR every Monday."
Cron applies day-of-month and day-of-week with OR, not AND. If both fields are restricted (neither is *), the job fires on any day that satisfies either one. The above expression fires on the 1st of every month and on every Monday.
The convention is that you set one to * and constrain the other:
- Every Monday at 9am:
0 9 * * 1 - The 1st of every month at 9am:
0 9 1 * * - The 1st of every month but only when it's a Monday: not expressible in vanilla cron. You either have to script the date check inside the job, use a Quartz expression, or run daily and exit early.
This is the bug that catches everyone exactly once.
The macros
Vixie cron added named shortcuts:
| Macro | Meaning |
|---|---|
@reboot |
Once at system startup |
@yearly, @annually |
0 0 1 1 * |
@monthly |
0 0 1 * * |
@weekly |
0 0 * * 0 |
@daily, @midnight |
0 0 * * * |
@hourly |
0 * * * * |
These read better than their numeric equivalents and prevent the 0 0 * * * typo where you accidentally wrote 0 * * * *. Use them when they fit. The one to avoid is @reboot β its semantics differ across cron implementations, and on systemd-managed hosts it's often outright ignored.
Quartz: the 6-field cousin
The Java ecosystem standardized on a different cron syntax popularized by the Quartz scheduler. The differences:
- Six fields, not five β a leading
secondsfield. - An optional seventh field for year.
- A
?placeholder for day-of-month / day-of-week, used when the other field is set, to avoid the OR ambiguity above. - An
Lmodifier βLin day-of-month means "last day of the month,"5Lin day-of-week means "last Friday of the month." - A
Wmodifier β15Wmeans "the weekday closest to the 15th." - A
#modifier β1#3in day-of-week means "the third Monday of the month."
Quartz expressions are strictly more expressive than Vixie cron. They're also incompatible. A Vixie expression pasted into a Quartz config will fail to parse because Quartz expects six fields. A Quartz expression pasted into Linux cron will misbehave because the leading number gets read as minute.
If you see 0 0 9 ? * MON-FRI, that's Quartz. If you see 0 9 * * 1-5, that's Vixie. The shape gives it away.
DST: where cron silently does the wrong thing
Cron jobs run on wall-clock time. On the day clocks spring forward, the missing hour means a job scheduled for that hour either:
- Doesn't fire at all (most modern Vixie cron implementations).
- Fires at the next available wall-clock minute (some).
On the day clocks fall back, the repeated hour means a job scheduled for that hour either:
- Fires once (most modern implementations track UTC progression internally).
- Fires twice (older implementations).
If you have a job at 0 2 * * * and your timezone observes DST, you should know which behavior your scheduler chooses, and you should usually move the job to a time that doesn't transition β 0 5 * * * or 0 12 * * *. If it absolutely must run during the transition, schedule it in UTC and let the tz database do the work.
Better still: if the system supports it (SYSTEMD_OnCalendar, BSD cron's CRON_TZ, or running cron with TZ=UTC), express the schedule in UTC explicitly. Wall-clock cron + DST + scheduled batch jobs is a recipe for the same subtle bug filed every two years.
The off-by-zero problem
Almost every cron expression people write hits minute 0 of an hour. 0 * * * * for hourly. 0 9 * * * for 9am.
This means almost every cron job in the world fires on the same instant. An external API receiving cron-driven traffic gets a thundering herd at :00:00.000 of every minute, hour, and day. Your 5,000 RPS API is really 0 RPS, then 5,000 instantaneous.
The pragmatic answer: jitter your schedules. Pick */5 instead of 0,5,10,15..., schedule the report for 0 7 * * * instead of 0 9 * * *, run hourly jobs at minute 17 instead of minute 0. The system you're hitting will thank you.
(Yes, I'm aware this advice is itself a coordination problem if everyone follows it. Pick a number off the beaten path.)
Common pitfalls
- The day-of-month / day-of-week OR trap. Constrain one, not both.
- Empty
PATH. Cron jobs run with a minimal environment β usually just/usr/bin:/bin. Anything in/usr/local/binor your shell'sPATHwon't be found. Either setPATH=at the top of the crontab or use absolute paths. - Missing trailing newline. Some cron implementations silently ignore the last line if it doesn't end in
\n. - Output buffering. A cron job that prints to stdout sends it to the user's local mail; if mail isn't configured, you lose the output entirely. Redirect to a log file or pipe to
logger. - Long-running jobs overlapping with the next firing. Cron doesn't serialize. Use a lockfile (
flock) or a job-runner that does. - Quartz/Vixie syntax confusion. They look almost identical and behave very differently.
Practical rules
- Use
crontab -eandcrontab -l, never edit/var/spool/crondirectly. - Set
SHELL,PATH, andMAILTOat the top of the crontab. - Wrap every command in absolute paths.
/usr/bin/curl, notcurl. - Always redirect stdout and stderr:
>> /var/log/myjob.log 2>&1. - Never schedule across a DST transition without testing both directions.
- Avoid minute :00 and hour :00 unless the timing actually matters β jitter to anything else.
- Test new expressions against the next 5 firing times before deploying. Tools like crontab.guru exist for a reason.
Read and write cron expressions
The crontab tool on this site translates expressions to plain English, generates next-N firing times in a chosen timezone, and warns about common pitfalls (DST gaps, day-of-month + day-of-week conflicts). All client-side.
Open the cron toolRelated guides
Keep the session useful with adjacent reading instead of exiting after one article.
QR Codes, Properly Explained
How QR codes actually work β finder patterns, Reed-Solomon error correction, static vs. dynamic redirects, and the real reasons codes fail in print.
Base64, Properly Explained
A 1989 hack for smuggling binary through 7-bit email transports β and why we still use it for JWTs, data URIs, and a hundred other places. Two alphabets, one common decode failure, and the things it categorically isn't.
URL Encoding, Properly Explained
Why %20 and + both mean space, why encodeURI and encodeURIComponent are not interchangeable, and how the HTML form spec quietly invented its own incompatible variant. RFC 3986 vs application/x-www-form-urlencoded.