Guide
Cron Expression Tutorial: How to Read and Write Crontab Schedules
Five fields, six special characters, and one historical bug that decides whether your job runs once or twice.
By Buğra SözeriPublished
Cron is forty years old and still the default scheduler on almost every Unix system shipped this year. The syntax fits on one line, which is most of why it survived — and also why it’s reliably misread. This guide walks through the five fields, every special character, the day-of-month vs day-of-week quirk, and a small library of expressions you can copy directly into a crontab.
The five-field anatomy
A POSIX cron expression has five whitespace-separated fields, in this order:
# ┌──────────── 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, where 0 and 7 are Sunday
# │ │ │ │ │
* * * * * command-to-runEvery field must be present. There are no default values and no positional shortcuts. A field with *means “every valid value in this range,” not “skip.” That distinction matters as soon as you start combining fields.
Some flavours add a sixth seconds field at the start (Quartz, Spring’s @Scheduled) or a seventh year field at the end. Those are extensions, not POSIX. If you’re writing for portability, stick to five.
Why two day fields?
Cron lets you say “every Monday” and “the first of the month” in the same line, which would be ambiguous if both fields had to agree. The historical resolution is OR semantics when both fields are restricted: the job fires when day-of-month or day-of-week matches. We’ll come back to this; it’s the single most common cron bug.
Special characters
Each field accepts six special characters. Their behaviour is identical across fields, but the valid range obviously differs per field.
* — every value
Match every valid value in the field. * * * * * means every minute of every hour of every day. This is the only expression that genuinely fires sixty times an hour; anything more complex usually fires less.
, — list
Comma separates discrete values. 0,15,30,45 * * * * fires at the top, quarter past, half past, and quarter to of every hour. Order doesn’t matter to the parser, but readers appreciate ascending lists.
- — range
Hyphen defines an inclusive range. 0 9-17 * * 1-5 fires at the top of every hour from 09:00 to 17:00 inclusive, Monday through Friday. Both endpoints are included; that’s nine runs per day, not eight.
/ — step
Slash applies a step to a range. */5 in the minutes field is shorthand for 0-59/5— every five minutes starting from minute 0. You can combine the step with an explicit range: 0-30/10 fires at 0, 10, 20, 30 and nowhere else.
The trap: */N divides the full range, not from your current clock time. */7on minutes fires at 0, 7, 14, 21, 28, 35, 42, 49, 56 — then 0 of the next hour, a four-minute gap. Cron is not a periodic timer.
? — no specific value (Quartz only)
The ? character is notPOSIX. It appears in Quartz-flavoured cron (and AWS EventBridge, which inherits Quartz syntax) and means “no specific value — the other day field is authoritative.” You’ll write 0 9 ? * MON-FRI in EventBridge but 0 9 * * MON-FRI in classic cron.
Named values
Months accept JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC, weekdays accept SUN MON TUE WED THU FRI SAT. They are case-insensitive in most implementations but case-sensitive in a few; uppercase is the safe choice. Named values do not work inside ranges or steps on all implementations — MON-FRI works on Vixie cron but not on every BSD variant. When in doubt, use the numeric form.
Recipes
The fastest way to internalise cron is to read a few dozen expressions next to plain English. Paste any of these into our cron expression builder to see the next ten fire times in your local zone.
Every-N-minutes
* * * * *— every minute*/5 * * * *— every five minutes, on minute 0, 5, 10…*/15 * * * *— every quarter hour0,30 * * * *— on the hour and the half hour5,35 * * * *— offset by five minutes — useful for staggering jobs that compete for the same database
Hourly variants
0 * * * *— every hour on the hour0 */2 * * *— every two hours starting at 00:000 9-17 * * *— on the hour, 09:00 to 17:00 inclusive
Daily variants
0 0 * * *— midnight every day (equivalent to@daily)30 3 * * *— 03:30 every day — the classic backup window0 9 * * 1-5— 09:00 on weekdays0 18 * * 5— 18:00 on Fridays only
Weekly, monthly, yearly
0 9 * * 1— 09:00 every Monday (equivalent to@weekly’s spirit but with a sensible time)0 0 1 * *— midnight on the first of every month (equivalent to@monthly)0 0 1 1 *— midnight on January 1 (equivalent to@yearly)0 0 28-31 * *— midnight on the last possible four days of the month, paired with a script that checks “is this the last day?” — cron has no native “last day of month.”
Business hours
0 9-17 * * 1-5— on the hour, 09:00-17:00, Monday-Friday — nine fires per workday*/10 9-17 * * 1-5— every ten minutes during US business hours0 9,13,17 * * 1-5— opening, post-lunch, closing — three fires per workday
The day-of-month / day-of-week OR-quirk
This is the single most-asked-about behaviour in cron, and the spec is unambiguous: if both the day-of-month field and the day-of-week field are restricted (i.e. not *), the job fires when either condition matches, not both.
Consider 0 0 1 * 1. A natural reading is “midnight on the first of the month, but only if it’s a Monday.” The actual behaviour is “midnight on the first of every month, ormidnight on every Monday.” That’s roughly five fires per month, not the zero-to-one you probably intended.
The workaround: leave one of the two day fields as *. If you genuinely need AND semantics — “first Monday of the month” — you’ll have to check inside the script:
# crontab: fire midnight on the first seven days of the month
0 0 1-7 * * /usr/local/bin/maybe-run-monthly.sh
# inside maybe-run-monthly.sh: bail if today isn't Monday
[ "$(date +%u)" = "1" ] || exit 0Quartz-flavoured cron (and EventBridge) sidesteps the quirk with the ? character and explicit nth-weekday-of-month tokens like MON#1. Classic cron has neither.
Timezones and DST
Cron expressions are evaluated in the scheduler’s local timezone. On Linux that’s whatever /etc/localtime is symlinked to; on macOS it’s the system preference. The expression itself never names a zone.
That makes daylight-saving transitions interesting. In a DST-observing zone the local clock jumps forward (one hour skipped) in spring and falls back (one hour repeated) in autumn. A cron job at 0 2 * * *may not fire at all on the spring-forward day — the 02:00 hour didn’t exist — and may fire twice in autumn.
Most cloud schedulers accept an explicit timezone per rule; IANA zone identifiers like America/New_York are correct, ESTis not (it doesn’t observe DST). If you need absolute consistency, schedule between 03:00 and 23:00 (well clear of any DST window) or run the scheduler in UTC and live with the local-time arithmetic.
Common mistakes
Treating the slash as a true period
We covered this above. */N divides the field range, so the period between the last fire of one hour and the first fire of the next is shorter than N. If you need strict N-minute spacing, cron is the wrong tool.
Forgetting that minute 0 exists
0-59/15fires at 0, 15, 30, 45 — four times an hour, not three. The range starts at 0. Off-by-one bugs are rare in cron because every field has well-defined endpoints, but this one comes up when people translate from human English (“four times an hour”) to expression and forget the implicit minute zero.
Putting a comment after the command
Cron treats the entire line after the fifth field as a single command. # comment on a crontab line is part of the shell command, which usually just expands to nothing because #starts a comment in the shell — but if it’s inside a quoted string, you’ve changed your command. Put comments on their own line above the schedule.
Assuming the job’s PATH
Cron runs with a minimal environment. PATH is typically /usr/bin:/binand your shell aliases don’t exist. Either use absolute paths to every binary or set PATH=at the top of the crontab. If a job “works when I run it by hand but not from cron,” this is almost always the cause.
Letting stdout fill the inbox
Cron emails the output of every job to the local user by default. On a server with no mail configured, the spool file grows quietly until something else breaks. Append >>/var/log/myjob.log 2>&1 to every command, and rotate the log.
Quick reference card
| Expression | Meaning |
|---|---|
* * * * * | every minute |
*/5 * * * * | every 5 minutes |
0 * * * * | top of every hour |
0 9-17 * * 1-5 | hourly, 9-5, weekdays |
0 0 * * * | midnight daily |
30 3 * * 0 | 03:30 every Sunday |
0 0 1 * * | midnight on the 1st of each month |
0 0 1 1 * | midnight on Jan 1 |
@reboot | once at scheduler start (system-dependent) |
Verify before you commit
The cheapest way to avoid a 3am page is to paste your expression into a parser before saving the crontab. Our cron expression builder prints the next ten fire times in your local zone and in UTC, plus a plain-English description that catches the day-of-month / day-of-week quirk on the spot.
Cron is a small, sharp tool. Read the spec once, build a muscle memory for the five fields, paste new expressions into a parser until they look obviously right — and budget for the fact that the failure mode of a misread expression is usually silent, not loud.
Frequently asked questions
- Does cron use the server timezone or UTC?
- Classic Unix cron uses the system local timezone, which is whatever /etc/localtime points to. Many cloud schedulers (AWS EventBridge, GCP Cloud Scheduler) default to UTC and let you opt into a named IANA zone per rule. Always print the resolved timezone in your job's first log line — it removes an entire category of post-DST bugs.
- Why did my job run twice the morning DST ended?
- When clocks fall back from 02:00 to 01:00, the hour between 01:00 and 02:00 happens twice on the local clock. A job scheduled at 01:30 in a DST-observing zone runs both times unless your cron implementation explicitly de-duplicates. The fix is either to schedule between 03:00 and 23:00 (never inside the ambiguous window) or to run the scheduler in UTC.
- What's the smallest interval cron supports?
- One minute. The first field is minutes; there is no seconds field in POSIX crontab. If you need sub-minute scheduling, use a long-running process with its own sleep loop, systemd timers with OnUnitActiveSec, or a dedicated scheduler like Quartz that supports a seconds field.
- Are @hourly, @daily, @reboot portable?
- The shorthand strings (@yearly, @monthly, @weekly, @daily, @hourly) are supported by Vixie cron, cronie, and most modern derivatives, so they're safe on Linux and macOS. @reboot is widely supported but its semantics differ — some implementations run once at the next boot, others run every boot. Don't use @reboot in containers; use the container's own start command instead.
- Why does */7 sometimes skip the last interval?
- The step operator divides the entire allowed range, not your start time. For minutes (0-59), */7 fires at 0, 7, 14, 21, 28, 35, 42, 49, 56 — then the next valid minute is 0 of the next hour. The gap between 56 and 60 is only four minutes, not seven. If you need a strict seven-minute period, use a long-running scheduler with a real interval timer, not cron.
- Should I use cron in 2026, or systemd timers?
- On a single Linux host with no orchestrator, systemd timers are usually a better default: structured logging via journalctl, dependency ordering, easier randomization with RandomizedDelaySec, and unit-level resource limits. Cron wins when you need portability across non-systemd systems or when the entire automation is one line in a crontab. In container-orchestrated environments, neither — use the platform's native scheduler (Kubernetes CronJob, ECS Scheduled Tasks, etc.).
Sources & references
Authoritative references cited by this piece. Verified by Buğra Sözeri on the dates shown and re-checked at every deploy.
- POSIX crontab(5) — The Open Group Base Specifications Issue 7 — Canonical spec for the five-field syntax and field ranges(as of )
- crontab(5) — Vixie cron / Linux man page — Reference for step values, named months/weekdays, and the @-shortcuts(as of )
- systemd.timer(5) — freedesktop.org — Modern alternative used for the comparison in the FAQ(as of )
- IANA Time Zone Database — Authoritative source for zone identifiers referenced in the DST discussion(as of )
Related
- Cron expression builder & explainerPaste an expression, see the next runs and a plain-English description
- IANA timezone glossaryWhy zones are written America/New_York, not EST
- Unix timestamp glossarySeconds since 1970-01-01 UTC — the basis for almost every scheduler
- Timestamp converterConvert between epoch seconds, ISO 8601, and your local timezone
- Timezone cheatsheet for remote teamsPractical zone math for distributed scheduling
Published May 31, 2026