How to Read a Cron Expression (and Write One That Runs When You Think)
Cron is five fields in fixed positions and four symbols. Read any schedule on sight, and dodge the two traps that fire a correct-looking line at the wrong time.
A pull request adds one line — */15 9-17 * * 1-5 — and the review stalls on a question nobody can answer out loud: when does this actually run? Someone guesses “every 15 minutes?” Someone else adds “during the day, on weekdays, I think.” Both are close, and neither is sure.
Cron expressions look like a cipher, but there’s almost nothing to memorize. It’s five fields in fixed positions and four symbols. Once you can read the positions, */15 9-17 * * 1-5 says exactly one thing — within a given cron dialect — and it says it every time. The part that trips people up isn’t the syntax — it’s two traps where a schedule that reads correctly still fires at the wrong time. We’ll get to both.
Five fields, and position is everything
A standard cron line is five fields separated by spaces. Their meaning comes entirely from where they sit — there are no labels, so 30 6 and 6 30 are different times, not the same one written two ways.
Left to right:
| Position | Field | Range |
|---|---|---|
| 1 | Minute | 0–59 |
| 2 | Hour | 0–23 |
| 3 | Day of month | 1–31 |
| 4 | Month | 1–12 |
| 5 | Day of week | 0–7 (0 and 7 are both Sunday) |
So 30 6 * * * is “at 6:30” — minute 30, hour 6, and the three stars mean any day of month, any month, any day of week. Read a cron line by walking those five slots and saying each one aloud: at minute __, hour __, on day __, in month __, on weekday __.
Two of the fields overlap in a way that surprises people, and it’s the source of the first big trap — hold that thought until the day-of-month vs day-of-week section below.
Months and weekdays also accept three-letter names in most parsers: JAN–DEC and SUN–SAT. 0 9 * * MON reads better than 0 9 * * 1, and a name sidesteps the numbering differences between platforms (more on that below).
Four symbols do all the work
Every field takes the same four symbols. Learn them once and you can read the common five-field forms.
*— every value. A star in the hour field means every hour. Five stars means every minute of every day.,— a list.0,30in the minute field is “at :00 and :30.”MON,WED,FRIis those three days.-— a range.9-17in the hour field is “9 through 17 inclusive” (that’s 9am to 5pm).MON-FRIis the work week./— a step.*/15in the minute field is “every 15” — 0, 15, 30, 45. You can step inside a range too:9-17/2is every other hour from 9 to 17.
That last one causes the most confusion, so it’s worth pinning down:
5 * * * * # at minute 5 of every hour — once an hour
*/5 * * * * # every 5 minutes — twelve times an hour
5 is a single value: minute 5, and only minute 5. */5 is a step across all values: 0, 5, 10, … A plain number is a point; a slash is a rhythm. Mixing them up is how “every five minutes” quietly becomes “once an hour.”
There’s a subtler version of the same mistake. A step counts from the start of the field’s range, not from “now” — and the range resets every hour. So */35 in the minute field is not “every 35 minutes.” It fires at minute 0 and minute 35, then the hour rolls over and it starts again at 0 — giving you a 35-minute gap followed by a 25-minute one, forever. Any step that doesn’t divide its range evenly does this. If you need a true 35-minute cadence, a single cron field can’t express it.
Now the opening example reads cleanly. */15 9-17 * * 1-5: every 15 minutes, during hours 9 through 17, any day of the month, any month, on weekdays 1 through 5 — Monday through Friday in standard cron. Note that hour 17 is included, so it keeps firing through 17:45; the schedule runs from 09:00 to 17:45 on weekdays, not until 17:00. If you’d rather not translate in your head, paste it into the cron expression reader — it prints the plain-English version, breaks out each field with its valid range, and lists the upcoming runs (five by default, up to 50).
Numeric weekdays aren’t portable. Standard cron and GitHub Actions count from
0, with0= Sunday. Cloudflare Workers count from1, with1= Sunday through7= Saturday, and Kubernetes treats0= Sunday. So1-5is Monday–Friday in a Linux crontab but Sunday–Thursday on Cloudflare. Spell weekdays as names where your platform accepts them —MON-FRI,FRI— and the numbering stops mattering.
The day-field trap: day-of-month and day-of-week
Here’s the one that has burned nearly everyone. What does this run?
0 0 13 * FRI
Minute 0, hour 0, day-of-month 13, any month, day-of-week Friday. It looks like “midnight on Friday the 13th.” It is not. On standard Unix cron (the Vixie/ISC lineage most Linux crontabs use), when both the day-of-month field and the day-of-week field are restricted — neither is * — cron runs the job when either one matches. So 0 0 13 * FRI fires at midnight on the 13th of every month, and also on every Friday. It’s an OR, not an AND.
The rule in one sentence: if either day field is *, the other one simply applies; but the moment both are set, they combine with OR, not AND. That’s why there’s no simple cron line for “Friday the 13th” — you can’t ask for both conditions at once.
This is also the field pair where implementations disagree the most, so treat the OR behavior as the common default rather than a universal guarantee, and check your platform’s docs when both day fields are set. The reader flags this case specifically when it sees both fields restricted, so you’re warned before it bites.
The other trap: which timezone does it run in?
A cron expression has no timezone in it. 0 9 * * * is “9:00” — but 9:00 where? The answer is decided outside the expression, and getting it wrong is how a “9am” job pages someone at 2am.
Classic crontab runs in the server’s local timezone. In Cronie-compatible crontabs you can override that with a CRON_TZ setting at the top of the file — but don’t assume a plain TZ environment assignment changes the scheduling timezone, and don’t put either prefix inside a Kubernetes spec.schedule; Kubernetes rejects that and uses a separate spec.timeZone field. Move the server, or let the datacenter default to UTC, and the same line fires at a different wall-clock time. Most hosted schedulers sidestep the ambiguity by fixing everything to UTC — GitHub Actions schedule: triggers and Cloudflare Workers cron triggers both interpret your expression in UTC, full stop.
Daylight saving time adds a second layer, but only where cron matches against local wall-clock time — Vixie/Cronie-style crontabs. There, a job set for 2:30am doesn’t run on the spring-forward day (that wall-clock time doesn’t exist) and can run twice on the fall-back day. UTC-based platforms have no local DST jump at all; only the way a run time is displayed in your zone shifts. If any of this matters, don’t reason about it in your head — the reader lets you pick a timezone and shows each upcoming run in both that zone and UTC, so DST shifts are visible. For the difference between UTC, an offset, and a zone in the first place, see UTC, GMT, ISO 8601, and Unix time.
Five fields, six fields, and the @daily shortcuts
Traditional Unix cron is five fields. Some schedulers add a field for seconds at the front: Spring and many programming-language cron libraries use a six-field form, while Quartz uses six required fields plus an optional seventh for the year. So */30 * * * * * (six fields) is “every 30 seconds,” while */30 * * * * (five fields) is “every 30 minutes.” Count the fields before you read anything else; a stray leading field changes the meaning of all of them.
Cronie- and Vixie-style crontabs also understand a set of named shortcuts that replace the five fields entirely:
@yearly # 0 0 1 1 * — once a year, midnight Jan 1
@monthly # 0 0 1 * * — midnight on the 1st
@weekly # 0 0 * * 0 — midnight on Sunday
@daily # 0 0 * * * — every midnight (@midnight is the same)
@hourly # 0 * * * * — top of every hour
@reboot # — once, at startup
They’re convenient, but not universal — they’re an extension only some crontab implementations support. @reboot in particular needs a machine with a boot lifecycle, so hosted schedulers that don’t have one generally don’t accept it (it may be rejected or simply unsupported). When you want to inspect a shortcut, expand it to its five-field form first: the reader analyzes the numeric five- and six-field syntax, so 0 0 * * * gives you the field breakdown and next-run list that @daily on its own won’t.
? L W # and other dialect extensions
If you see ?, L, W, or #, you’re looking at a dialect extension, not generic cron (? for “no specific value,” L for “last,” W for “nearest weekday,” # for “nth weekday of the month”). They’re most associated with Quartz, but they aren’t Quartz-only — Cloudflare Workers, for one, supports a subset of L, W, and #. And the same character can mean different things: Kubernetes accepts ? but simply treats it as *, not as Quartz’s “no specific value.” So don’t assume a single meaning — read the docs for the platform you’re targeting.
That’s the real lesson of the whole post: the syntax is easy, but the dialect decides what it means. Before you deploy a line, check it against where it will actually run.
| Target | Fields | Timezone | Weekday numbers | Extensions |
|---|---|---|---|---|
| Linux / Cronie | 5 | server, or CRON_TZ | 0–7, 0 & 7 = Sunday | @ macros |
| GitHub Actions | 5 | UTC | 0–6, 0 = Sunday | no seconds |
| Cloudflare Workers | 5 | UTC | 1 = Sunday … 7 = Saturday | subset of L W # |
| Kubernetes CronJob | 5 | .spec.timeZone | 0 = Sunday | ? acts as * |
| Quartz | 6–7 | scheduler config | dialect-specific | ? L W # |
Two practical notes the table can’t hold: GitHub Actions runs schedules on a best-effort basis and can delay them under load, so it’s not a precise timer; and Windows has no cron at all — reach for Task Scheduler or schtasks.
A reading checklist
When a cron line lands in front of you:
- Count the fields. Five is standard; six means the first one is seconds; a shortcut like
@dailyis its own thing. - Read the five positions in order — minute, hour, day-of-month, month, day-of-week — and say each aloud.
- Expand the symbols:
*is every,,is a list,-is a range,/is a step. Remember5(a point) is not*/5(a rhythm). - Check the two day fields. If both day-of-month and day-of-week are set, standard cron treats it as an OR — the job runs on either (confirm on your platform).
- Ask which timezone the runtime uses — server-local or UTC — because the expression doesn’t say.
Do that and every cron line becomes readable on sight. When you’d rather confirm than translate — especially the day-field and timezone traps — drop the expression into the cron expression reader: plain-English description, per-field ranges, and the exact next run times in the timezone you choose.
And if a line you’ve confirmed as correct still doesn’t do anything, that’s a different problem — not the syntax but the environment, the crontab’s location, or the host itself. Why Didn’t My Cron Job Run? is the field guide for that half.