design
Cron Syntax Cheatsheet (2026): Read and Build Any Crontab Expression
Cron syntax explained: the 5-field order, a 10-second reading model, 16 verified recipes, the day-of-week OR gotcha, DST traps, and cron vs systemd timers.
Published 2026-07-04 · 10 min read
Affiliate disclosure
Some links below are affiliate links. I may earn a commission from qualifying purchases at no extra cost to you. I only recommend tools I have used or tested.

TL;DR
- Five fields, in this order:
minute hour day-of-month month day-of-week. Leave any field you don't care about as*.- The gotcha that bites everyone: if you restrict both day-of-month and day-of-week, Vixie cron runs the job when either matches (an OR, not an AND). Restrict only one and leave the other
*.- Test yours before you deploy: paste any expression into the Cron Parser to see the exact next run times in plain English.
You need a script to run every night at 2 AM. You open crontab -e, stare at five asterisks, and realise you're not sure which one is the hour. This guide fixes that permanently: read any cron line in ten seconds, build the one you need from a verified recipe, and know the two or three traps that silently break jobs in production.
How do you read any cron expression in 10 seconds?
Read it left to right as five fields, always in this exact order:
┌───────────── minute (0-59)
│ ┌─────────── hour (0-23)
│ │ ┌───────── day of month (1-31)
│ │ │ ┌─────── month (1-12)
│ │ │ │ ┌───── day of week (0-7, 0 and 7 = Sunday)
│ │ │ │ │
* * * * * command to run
The whole mental model: you only ever control three things — the minute, the hour, and one day-selector — and leave every field you don't care about as *. An asterisk means "every value of this field," so * * * * * runs the command every single minute, forever. Every recipe you'll write is that baseline with one to three fields pinned down.
So 30 2 * * * reads as: minute 30, hour 2, every day-of-month, every month, every day-of-week — i.e. 2:30 AM every day. The command after the fifth field is what runs; it isn't a sixth time field. That's the whole trick. Field ranges and the asterisk meaning are defined in the crontab(5) man page and the POSIX crontab spec; when in doubt, verify a line in the Cron Parser instead of guessing.
What does each of the five fields mean?
Each field has a fixed range and a couple of quirks worth knowing up front. This is the reference you'll come back to.
| Field | Range | Notes |
|---|---|---|
| Minute | 0-59 | Minute of the hour. 0 is the top of the hour. |
| Hour | 0-23 | 24-hour clock, system local time. 0 is midnight, 23 is 11 PM. |
| Day of month | 1-31 | Starts at 1, not 0. There is no native "last day of month." |
| Month | 1-12 | 1 is January. Three-letter names (JAN–DEC) also work in most implementations. |
| Day of week | 0-7 | 0 and 7 both mean Sunday, 1 is Monday, 6 is Saturday. Names (SUN–SAT) also work. |
Two range facts trip people up. Day-of-month starts at 1 while the time fields (minute, hour) start at 0. And day-of-week has two Sundays: the crontab(5) man page defines it as 0-7 where "0 or 7 is Sunday," so both 0 (US week-start) and 7 (ISO week-end) resolve correctly. The POSIX spec is stricter — 0-6 with 0=Sunday — so on a minimal POSIX cron, stick to 0-6 and don't rely on 7.
What do *, ,, -, and / do?
Four characters cover every standard cron pattern. Anything beyond these is a non-standard extension you should not assume is supported.
| Character | Name | Meaning | Example |
|---|---|---|---|
* | Asterisk | Every value ("first–last") of the field | * * * * * = every minute |
, | Comma | A list of specific values | 0 0,12 * * * = midnight and noon |
- | Hyphen | An inclusive range | 0 9-17 * * * = every hour 9 AM–5 PM |
/ | Slash | A step ("every Nth") over a range or * | */5 * * * * = every 5 minutes |
The one subtlety is what the step counts from. A step on an asterisk starts at the field's first value: */5 in the minute field means minutes 0, 5, 10 … 55. A step on an explicit range starts at the range's low end: 10-50/5 means 10, 15, 20 … 50 — it starts at 10, not 0. Get this wrong and you get "why did it fire at a weird minute" bugs, so test a stepped schedule in the Cron Parser if it surprises you.
Everything else — L (last), W (nearest weekday), # (nth weekday of month), ? (no-specific-value), and a leading seconds field — is non-standard. Per Wikipedia's cron reference, those characters "exist only in some cron implementations, such as the Quartz Java scheduler," and the seconds field appears only in 6- and 7-field variants. Classic Vixie/POSIX cron (the crontab -e on your box) has none of them. Copy a 0 0 12 * * ? expression from a Quartz tutorial and it won't parse in a normal crontab — that leading 0 is a seconds field classic cron doesn't have.
The recipe table — 16 verified expressions to copy
Every expression below has been checked field-by-field against the ranges above. Copy the one you need; when you tweak it, verify the result in the Cron Parser.
| Expression | Plain English |
|---|---|
*/5 * * * * | Every 5 minutes |
*/15 * * * * | Every 15 minutes |
*/30 * * * * | Every 30 minutes (on the hour and half-hour) |
0 * * * * | Every hour, on the hour |
0 */2 * * * | Every 2 hours (00:00, 02:00, 04:00 …) |
0 */6 * * * | Every 6 hours (00:00, 06:00, 12:00, 18:00) |
0 0 * * * | Every day at midnight |
0 9 * * * | Every day at 9:00 AM |
0 0,12 * * * | Twice a day — midnight and noon |
0 9 * * 1-5 | 9:00 AM on weekdays (Mon–Fri) |
0 9-17 * * 1-5 | Every hour from 9 AM to 5 PM, weekdays |
0 0 * * 0 | Every Sunday at midnight |
0 8 * * 6 | Every Saturday at 8:00 AM |
0 18 * * 1,3,5 | 6:00 PM on Mon, Wed, and Fri |
0 0 1 * * | Midnight on the 1st of every month |
0 0 1 1,4,7,10 * | Midnight on the 1st of Jan, Apr, Jul, Oct (quarterly) |
Notice the pattern: every single one is * * * * * with only the fields it cares about pinned down. "Every day at 9 AM" (0 9 * * *) touches just the minute and hour and leaves the three day/month fields as *. "Weekdays 9 to 5" (0 9-17 * * 1-5) touches the minute, an hour range, and the day-of-week — and critically, it leaves day-of-month as * so the OR gotcha (next section) never triggers.
Why does restricting both day fields cause an OR, not an AND?
Because that's how Vixie cron is specified — and it's the single most surprising rule in the whole system.
Normally, cron requires all fields to match before it runs a job (a logical AND). The day fields are the exception. Per the crontab(5) man page, verbatim:
If both fields are restricted (i.e., are not
*), the command will be run when either field matches the current time.
The POSIX spec says the same ("any day matching either the ... day of month, or the day of week, shall be matched"), as does the original Vixie cron crontab(5). So take this expression:
0 0 1,15 * 5 → midnight on the 1st, the 15th, AND every Friday
If you meant "midnight on the 1st and 15th, but only if it's a Friday," this is wrong — it fires on the 1st, the 15th, and every Friday, because the two day fields are OR'd. The fix is baked into the reading model: restrict only one day-selector and leave the other as *. Want a specific day-of-month? Set day-of-week to *. Want a specific weekday? Set day-of-month to *. Restrict both only when you deliberately want the union. Cron has no built-in AND for the day fields; if you truly need "the 15th only if it's a Friday," gate it inside the script. Any expression restricting both day fields is worth pasting into the Cron Parser to see the real run dates first.
What are the @ shortcuts (@daily, @hourly, @reboot)?
Cron ships eight named shortcuts that expand to a standard expression, so you rarely need to hand-write the common ones. These are documented in crontab(5):
| Shortcut | Equivalent | Meaning |
|---|---|---|
@yearly / @annually | 0 0 1 1 * | Once a year, midnight on Jan 1 |
@monthly | 0 0 1 * * | Once a month, midnight on the 1st |
@weekly | 0 0 * * 0 | Once a week, midnight on Sunday |
@daily / @midnight | 0 0 * * * | Once a day, at midnight |
@hourly | 0 * * * * | Once an hour, on the hour |
@reboot | — | Once, after cron starts at boot |
@midnight is an exact alias for @daily (both are 0 0 * * *), and @annually is an exact alias for @yearly. @reboot is the odd one — it has no time-field equivalent because it fires once when cron starts up, not on a schedule. It's handy for starting a long-running helper at boot, but remember it runs when cron starts, which on most systems is early in boot before other services may be ready.
The gotchas that bite in production
Most cron pain isn't syntax — it's these five environmental traps. Learn them once and you'll debug the "it ran at the wrong time" tickets in seconds.
Timezone and DST. Cron uses the system's local time, not UTC, unless told otherwise — which makes daylight-saving transitions dangerous. In spring-forward, the clock jumps from 2 AM to 3 AM, so a job at 2:30 AM lands in an hour that doesn't exist and can be skipped. In fall-back, the 1–2 AM hour repeats, so a job in that window can fire twice. Modern Vixie/cronie cron has special handling for shifts under three hours that mitigates this for once-a-day jobs, but behavior varies by implementation, so the robust fix is to schedule critical jobs outside the 1–3 AM window or run the box in UTC. You can pin a zone with a CRON_TZ=America/New_York line above the job — but CRON_TZ only changes when cron fires; your script still sees the system timezone unless you export TZ yourself.
No native "last day of month." Classic cron has no L, so 0 0 31 * * simply won't run in February, April, June, September, or November. Run daily and check the date in the script, or run on the 1st and target "yesterday."
Step-on-range vs step-on-star. As above, */5 starts at 0 but 10-50/5 starts at 10. For "every 5 minutes but only in the back half of the hour," write the range explicitly.
Overlapping long runs. Cron doesn't check whether the previous invocation finished. If */5 fires a job that sometimes takes 8 minutes, two copies run at once — a frequent cause of "why did it run twice," duplicated work, or corrupted output. Wrap the command in flock (e.g. flock -n /tmp/job.lock -c 'your-command') so a second invocation exits immediately while the first holds the lock.
Minimal PATH and environment. Cron runs with a stripped-down environment and a minimal PATH (typically /usr/bin:/bin), not your shell's. A script that works in your terminal can fail under cron because a binary isn't on cron's PATH. Use absolute paths for binaries, or set PATH= at the top of the crontab.
Cron vs systemd timers vs cloud schedulers — when does each fit?
All three run things on a schedule; they differ in what they survive and what they cost to operate. Pick by failure mode, not by habit.
Cron is the universal default: on virtually every Unix box, no setup beyond the daemon already running, one line does the job. Its weaknesses are the gotchas above — it silently drops runs missed while the machine was off, has no built-in success/failure logging, and won't prevent overlaps. Reach for cron for simple, per-host, best-effort jobs.
systemd timers are the heavier, more robust option on modern Linux. A timer's OnCalendar= uses the same calendar concepts as cron, but the killer feature is Persistent=: per the systemd.timer(5) man page, when set, "the service unit is triggered immediately if it would have been triggered at least once during the time when the timer was inactive" — so a missed run catches up after downtime instead of vanishing. Timers also get journal logging, monotonic scheduling (OnBootSec=, OnUnitActiveSec=), and RandomizedDelaySec= to stagger fleets. The cost: you write a .timer and a .service unit instead of one line. Choose them when you need catch-up, logging, or dependency ordering.
Cloud schedulers (managed cron, container CronJobs, serverless schedulers) win when the job must survive the death of any single machine. They run independently of any host, centralise logging and retries, and scale horizontally. The trade-off is vendor lock-in, network dependency, and per-invocation cost. Choose them when the schedule is business-critical and can't depend on one box being up.
Whichever you pick, the expression is the same skill. The same test-before-you-ship discipline applies to regex; see the regex tester guide for that side of the toolbox.
The bottom line
Cron is five fields in a fixed order, and 95% of what you'll ever write is * * * * * with the minute, hour, and one day-selector pinned down. Learn the field order, memorise the four operators (* , - /), copy from the recipe table, and internalise the one rule that saves you from mystery runs.
Bottom line: Never restrict both day-of-month and day-of-week unless you actually want the OR — and paste every expression into the Cron Parser to read its real next run times before it touches a server.





