The five fields
| Position | Field | Range | Notes |
|---|---|---|---|
| 1 | Minute | 0–59 | |
| 2 | Hour | 0–23 | 24-hour clock; midnight is 0 |
| 3 | Day of month | 1–31 | ORed with day of week when both are restricted |
| 4 | Month | 1–12 or JAN–DEC | Names are case-insensitive, three letters |
| 5 | Day of week | 0–7 or SUN–SAT | Both 0 and 7 mean Sunday |
Each field accepts four operators. * means every value. A comma makes a list: 1,15,30. A hyphen makes an inclusive range: 9-17. A slash adds a step: */15 in the minute field means 0, 15, 30, 45, and 1-23/2 means every odd hour. Steps count from the start of the range, not from "now" — */20 fires at :00, :20 and :40, never at :07, :27 and :47.
*/40 in the minute field means minutes 0 and 40 only — after :40 the next value would be :80, which does not exist, so the hour restarts and you get a 20-minute gap. If you need an even split, pick a divisor of 60.The day-of-month / day-of-week trap
This is the single most misunderstood rule in cron, and it is genuinely counter-intuitive: when both the day-of-month and day-of-week fields are restricted, cron ORs them rather than ANDing them. 0 0 1 * MON does not mean "the first of the month, if it is a Monday". It means "the first of every month, and additionally every Monday" — roughly five times as often as intended.
Read the full explanation of cron expression generator results ↓Show less ↑
When only one of the two is restricted, it simply applies. Vixie cron (the implementation behind cronie, Debian's cron, and most others) decides "restricted" by whether the field starts with *, so */2 in day-of-month still counts as unrestricted for this rule. The preview on this page implements exactly that behaviour, which is why an expression with both day fields set shows the OR explicitly in its description.
If you genuinely need "the first Monday of the month", cron cannot express it. Schedule it for every Monday and guard the command: 0 9 * * 1 [ "$(date +\%d)" -le 07 ] && /usr/local/bin/report.
Escaping, PATH, and the other crontab gotchas
A crontab line is not a shell script, and several of its rules exist nowhere else:
- `%` is special. In the command portion, an unescaped
%is converted to a newline, and everything after the first one is fed to the command on stdin. This is whydate +%Y-%m-%dsilently produces nothing useful in a crontab. Writedate +\%Y-\%m-\%d, or put the command in a script file and call that instead — which is what you should be doing anyway. - PATH is minimal. cron typically runs with
PATH=/usr/bin:/binand nothing else. A command that works in your shell fails under cron because/usr/local/binis missing. Use absolute paths for everything, or setPATH=explicitly at the top of the crontab. - The shell is `/bin/sh`, not your login shell. Bash-isms like
[[ ]],source, and process substitution are not available unless you setSHELL=/bin/bashin the crontab. - Nothing from your login environment is loaded. No
.bashrc, no.profile, nonvm, norbenv, no virtualenv. If your job needs an interpreter version manager, invoke the absolute path to the real binary. - Every line must end with a newline. A crontab whose last line lacks one is rejected or silently ignored by some implementations.
- A system crontab has a sixth field.
/etc/crontaband files in/etc/cron.dinsert a username between the schedule and the command. A user crontab (crontab -e) does not. Pasting one into the other is a very common cause of "bad command" errors.
# Set the environment explicitly — cron gives you almost nothing
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
[email protected]
TZ=UTC
# m h dom mon dow command
*/5 * * * * /usr/local/bin/healthcheck.sh >> /var/log/healthcheck.log 2>&1
30 2 * * * /usr/bin/certbot renew --quiet
0 4 * * 0 /usr/local/bin/weekly-report.sh --date "$(date +\%F)"
0 9 1 * * /usr/bin/php /var/www/app/artisan invoices:sendThe >> logfile 2>&1 on the first line is not decoration. By default cron mails any output to the MAILTO address, and if no MTA is configured that output is discarded entirely — which is exactly why "my cron job does nothing and there is no error" is such a common complaint. Redirect to a file you can read, and never redirect to /dev/null while you are still debugging.
Timezones and DST
cron uses the system timezone unless a TZ= line overrides it for subsequent entries. Most servers run UTC, so a job written as 0 9 * * * fires at 09:00 UTC, which is 04:00 in New York during winter. The preview on this page is computed in your browser's timezone, which is convenient for sanity-checking but is not necessarily the server's — check with timedatectl before trusting it for production.
Daylight saving is where correctness gets genuinely hard. On the spring-forward night a job scheduled inside the skipped hour never fires that day; on the autumn night a job in the repeated hour may fire twice. Vixie cron has heuristics for jobs scheduled less than once an hour, but they are heuristics. The robust answer is to run the server in UTC and let the application do timezone conversion, or to make the job idempotent so a double run is harmless.
Nicknames and @reboot
Most cron implementations accept shorthand in place of the five fields: @yearly (0 0 1 1 *), @monthly (0 0 1 * *), @weekly (0 0 * * 0), @daily / @midnight (0 0 * * *), and @hourly (0 * * * *). This tool expands all of them when you paste one. @reboot is different — it runs once when cron itself starts, has no clock schedule, and therefore has no "next run" to compute. It also does not fire on a systemctl reload cron, and in many container images it never fires at all because cron is started before the thing it depends on.
Be sceptical of six-field expressions. If you found one in documentation, it is probably Quartz (Java) or Spring, where the first field is *seconds* — pasting it into a Unix crontab shifts every field by one and produces a schedule nothing like what you wanted. systemd timers use a completely different syntax again (OnCalendar=), and crontab will reject it outright.
Verifying on the server
crontab -l # list the current user's crontab
crontab -e # edit it (syntax is validated on save)
sudo crontab -l -u www-data # another user's crontab
# Did cron actually pick up and run the job?
grep CRON /var/log/syslog # Debian/Ubuntu
journalctl -u cron --since today # systemd
journalctl -u crond --since today # RHEL/Fedora
# What timezone is the daemon actually in?
timedatectl
# Run the command exactly as cron will, with cron's minimal environment
env -i /bin/sh -c '/usr/local/bin/myjob.sh'That last command is the fastest way to reproduce the classic "works in my shell, fails in cron" bug: env -i strips the environment down to nothing, which is much closer to what cron gives you than your interactive session is. If it fails there, the problem is PATH or a missing environment variable, not cron.
One more habit worth building: make jobs that must not overlap take a lock. flock -n /var/run/myjob.lock /usr/local/bin/myjob.sh exits immediately if a previous run is still going, which prevents the slow-job pile-up that turns a five-minute schedule into an outage.