Cron Syntax Cheat Sheet: Every Field, Every Edge Case
Cron syntax has five fields, reads left to right, and does not care about your intentions. Most of what goes wrong with cron jobs isn’t a bug in cron — it’s a gap between what a schedule expression looks like it means and what it actually does. This is a reference you can scan in ten seconds, plus the edge cases worth memorizing.
The five fields
A cron schedule is five space-separated fields, followed by the command:
┌───────────── minute (0–59)
│ ┌───────────── hour (0–23)
│ │ ┌───────────── day of month (1–31)
│ │ │ ┌───────────── month (1–12)
│ │ │ │ ┌───────────── day of week (0–6, Sunday=0)
│ │ │ │ │
* * * * * command to run
| Field | Range | Notes |
|---|---|---|
| Minute | 0–59 | |
| Hour | 0–23 | 24-hour clock, no AM/PM |
| Day of month | 1–31 | No leap-year or short-month validation — cron just skips the run if the date doesn’t exist |
| Month | 1–12 | Also accepts JAN–DEC |
| Day of week | 0–6 | 0 and 7 both mean Sunday; also accepts SUN–SAT |
Each field accepts an exact value, * (any value), a list (1,15,30), a range (1-5), or a step (*/5). These combine: 1-30/5 means “every 5th value from 1 through 30.”
*/N step values, and where they misbehave
A step value starts counting from the field’s minimum, not from “every N units from now.” */15 in the minute field means minutes 0, 15, 30, 45 — always aligned to the clock, never to whatever minute you happened to install the job.
The edge case: */7 on day-of-month does not mean “every 7 days.” It means “every day-of-month value from 1 that’s a multiple of the step,” i.e. days 1, 8, 15, 22, 29. Every month resets to day 1, so the interval between the last run of one month and the first run of the next is whatever’s left — anywhere from 1 to 8 days, not a clean weekly cadence. If you want an actual 7-day interval that doesn’t drift at month boundaries, don’t use day-of-month steps — use a day-of-week field instead (0 for a fixed weekday), or have your job self-check an interval in code.
Day-of-month AND day-of-week is actually OR, not AND
This is the single most misunderstood rule in cron. When both the day-of-month and day-of-week fields are restricted (not *), the job runs if EITHER condition matches — not both.
0 9 15 * 1 # runs on the 15th of the month, OR every Monday — not "the 15th if it's a Monday"
If you actually want “the 15th, but only when it falls on a Monday,” cron’s five fields cannot express that directly — you need the job to check the date itself, or use a scheduler with richer semantics.
The OR behavior only kicks in when both fields are restricted. If one of them is *, that field is ignored and the other one governs normally:
0 9 15 * * # the 15th of every month, day-of-week is *, so day-of-week doesn't participate
0 9 * * 1 # every Monday, day-of-month is *, so day-of-month doesn't participate
0 vs 7 for Sunday
Both 0 and 7 mean Sunday in the day-of-week field — POSIX cron accepts either, and most modern implementations (Vixie cron, cronie) support both. Don’t rely on 7 alone if you’re targeting an unfamiliar or embedded cron implementation; 0 is the safer, more universally supported choice.
Ranges with steps
1-10/2 means every 2nd value in the range 1 through 10: 1, 3, 5, 7, 9. This composes with lists too — 1-10/2,20 is valid and means that stepped range plus the literal value 20.
@ shorthand strings, and where they’re not portable
Most cron implementations (including Vixie cron and cronie, which back most Linux distributions) support shorthand strings in place of the five fields:
| Shorthand | Equivalent |
|---|---|
@reboot | Run once at startup |
@yearly / @annually | 0 0 1 1 * |
@monthly | 0 0 1 * * |
@weekly | 0 0 * * 0 |
@daily / @midnight | 0 0 * * * |
@hourly | 0 * * * * |
These are a convenience layer, not part of POSIX cron. They’re not guaranteed on every system — some embedded or minimal cron implementations don’t parse them at all, and @reboot specifically depends on the cron daemon detecting a reboot correctly, which behaves differently across systemd-managed vs. traditional init systems. If portability across machines matters, write out the five fields.
Timezone and DST behavior
Cron runs in the system timezone of the machine it’s on, not UTC and not the timezone of whoever wrote the schedule. A 0 9 * * * job means 9am in whatever /etc/timezone (or equivalent) says on that box — which is a routine source of confusion the moment you have jobs on servers in different regions, or you’re reading a schedule someone else wrote for a different machine.
Daylight Saving Time causes two distinct failure modes on systems using local time:
- Spring forward: a job scheduled for a time that gets skipped (e.g., 2:30am when clocks jump from 2:00 to 3:00) simply doesn’t run that day.
- Fall back: a job scheduled for a time that occurs twice (e.g., 1:30am when clocks fall back from 2:00 to 1:00) can run twice.
The fix is either to schedule cron-critical jobs in UTC (set CRON_TZ=UTC at the top of the crontab, or configure the system in UTC) or to accept the twice-a-year edge case and make the job idempotent so a double-run is harmless.
The crontab environment is nearly empty
Cron jobs do not inherit your shell’s environment, PATH, or profile. When you run a command interactively, your shell has already sourced .bashrc/.zshrc/.profile and built up a PATH that includes things like /usr/local/bin, nvm-managed Node versions, or your virtualenv. Cron starts each job with a minimal environment — typically just SHELL and a bare PATH like /usr/bin:/bin.
This is why “it works when I run it in my terminal” is one of the most common cron failure reports. The fix is to never rely on ambient environment inside a cron command: use absolute paths for binaries (/usr/local/bin/node instead of node), source what you need explicitly at the top of the script, or set PATH and other required variables directly in the crontab:
PATH=/usr/local/bin:/usr/bin:/bin
0 3 * * * /home/deploy/scripts/backup.sh
% needs escaping
The percent sign is special in crontab: it’s treated as a newline, and everything after the first unescaped % on a line is piped to the command as stdin. If your command legitimately needs a % — inside a date format string, for instance — escape it with a backslash:
0 0 * * * date +\%Y-\%m-\%d >> /var/log/datestamp.log
Missing trailing newlines
Some cron implementations (notably older Vixie cron) silently ignore the last line of a crontab file if it doesn’t end with a newline. If you’re editing crontab files by hand or generating them programmatically, always end the file with a trailing \n — an entry that looks correct but never fires is a hard bug to spot.
Worked examples
*/15 * * * * every 15 minutes, on the clock (:00, :15, :30, :45)
0 2 * * * once a day at 2am, system-local time
0 9 * * 1-5 9am, Monday through Friday
30 23 28-31 * * 11:30pm on the 28th through 31st — used to catch "last day of month" jobs, runs on every qualifying day
0 0 1 1,4,7,10 * midnight on the 1st of Jan/Apr/Jul/Oct — quarterly
@reboot /opt/scripts/startup.sh
CRON_TZ=UTC
0 0 * * * midnight UTC regardless of system timezone
FAQ
Does cron support seconds? No — standard cron’s finest granularity is one minute. Some tools built on top of cron (or alternative schedulers) add a seconds field, but plain crontab does not.
Can I run a job every N minutes that doesn’t divide evenly into 60? Not with a step value alone — */7 in the minute field still resets at the top of the hour, so it’s not a true rolling 7-minute interval. For an exact interval regardless of clock boundaries, you generally need the job itself to schedule its own next run, or a scheduler with interval semantics rather than field-based ones.
Why did my job run twice at the same time? Check whether both day-of-month and day-of-week are restricted (the OR trap above), and check for daylight saving time transitions if the run coincided with a clock change.
Is crontab -e the same syntax as /etc/crontab? The five schedule fields are identical, but /etc/crontab and files in /etc/cron.d/ have a sixth field for the user to run the command as, which per-user crontabs (edited via crontab -e) don’t have.
If you’re maintaining schedules across more than one box, norc gives you a live view of what’s actually in each machine’s crontab, catches the day-of-month/day-of-week OR trap and other syntax issues before you write, and keeps a history of every change. See how norc compares to raw cron and monitoring-only tools.