Cron expressions in 10 minutes, including the OR rule
Cron is the only language I know where you can be fluent and still Google "every 15 minutes" like it's a personal failing. Five fields, four operators, one rule that bites — and yet every team has that one expression someone copied from a 2014 Stack Overflow answer and nobody dares to touch.
Ten minutes from now you'll read cron the way you read your own handwriting. Here's the whole grammar.
- The five fields and what each one filters
- The four operators:
*,-/ - The day-of-month / day-of-week OR rule that pages people at 5am
@macros— and when they're safe to use- How to verify an expression before it runs in production
The five fields, in order
Every standard cron expression is five whitespace-separated fields, read left to right, each one a filter:
# ┌────────── minute (0–59)
# │ ┌──────── hour (0–23)
# │ │ ┌────── day of month (1–31)
# │ │ │ ┌──── month (1–12)
# │ │ │ │ ┌── day of week (0–7 · 0 and 7 both = Sunday)
# │ │ │ │ │
* * * * *
A job runs the moment all five filters match the current time. That's the entire mental model — cron isn't scheduling anything, it's pattern-matching against the clock.
Two footnotes before we move on:
- There's no seconds field. Standard cron is five fields, full stop. A six-field string came from Quartz or a similar scheduler, and your cron will either reject it or misread it.
- Day of week runs 0–7, and both 0 and 7 mean Sunday. Yes, really. It's historical, it's harmless, and now you won't side-eye a
7when you see one.
The four operators
Every field speaks the same small language:
* # any value
5 # exactly 5
1,15 # a list: 1 and 15
9-17 # a range: 9 through 17
*/15 # every 15 — in the minute field: 0, 15, 30, 45
9-17/2 # range + step: 9, 11, 13, 15, 17
The slash is the one people get wrong. */15 doesn't mean
"every 15 from now" — it means "every value divisible by 15, starting from
the field's floor." In the minute field that's 0, 15, 30, 45. The same idea
applies to a range: 9-17/2 walks from 9 in steps of 2.
And fields compose freely — 1,15 is a list, 1-5 is
a range, and 1-5,10,20-25 is all of it at once.
Reading real expressions
Grammar done. Now the reps:
*/15 9-17 * * 1-5 # every 15 min, 9am–5pm, Mon–Fri
0 5 * * 1 # Mondays at 05:00
0 0 1 * * # 1st of every month, at midnight
30 4 * * * # every day at 04:30
0 */6 * * * # every 6 hours
0 9 1,15 * * # the 1st and the 15th, at 09:00
That second one — 0 5 * * 1 — is "Mondays at 05:00." Hold that
thought, because in the next section we're going to break it in the most
instructive way possible.
Notice the shape: time-of-day lives on the left (minute, hour), calendar lives on the right (day of month, month, day of week). When you write one, say the schedule out loud in English first, then fill the fields left to right. Most mistakes come from filling them in the wrong order.
The OR rule — where the 5am pages come from
This is the one worth reading twice.
You want "the first Monday of the month, at 5am." You write:
0 5 1 * 1
# what it LOOKS like: "05:00 on the first Monday"
# what it MEANS: "05:00 on the 1st" OR "05:00 every Monday"
# → nine or ten runs a month, not one
Here's the rule, straight from the crontab man page: when
both the day-of-month and day-of-week fields are restricted
— meaning neither is * — the job runs when
either one matches. It's an OR, not an AND.
Two restricted day fields make a union. Either one can fire the job. This is documented, decades-old Vixie cron behavior — not a bug. It's just not what anyone expects the first time.
The fix is also the lesson. 0 5 * * 1 — day of month back to
* — is "Mondays at 05:00," because now only the day-of-week
field constrains the day. When one field is *, the other one
decides. When both are restricted, either one can trigger.
Standard cron can't express it. The honest pattern is: run every Monday
(0 5 * * 1) and let the script check the date —
[ "$(date +\%d)" -le 07 ] && /path/to/script. Cron
picks the weekday; your script picks the week. (And yes, that
% needs the backslash — keep reading.)
@macros, the readable shortcuts
For the common cases, cron ships named shortcuts:
@hourly → 0 * * * *
@daily → 0 0 * * *
@midnight → 0 0 * * *
@weekly → 0 0 * * 0
@monthly → 0 0 1 * *
@yearly → 0 0 1 1 *
@annually → 0 0 1 1 *
@reboot → runs once at startup — not time-based
The cron builder expands
these — type @daily and watch the five fields fill themselves
in, with the next five runs underneath. It's a good way to confirm your
system agrees with your memory.
One caveat: @reboot isn't a time at all — it fires once when the
cron daemon starts. And not every cron supports every macro (busybox is
picky), so when you're deploying somewhere minimal, the five-field form
always works.
Gotchas that aren't the OR rule
Three more, in descending order of how often they bite:
- Timezone. Cron runs on the machine's local time, not UTC — unless the machine is UTC. If a job "runs at the wrong time," check the box's timezone before anything else. Some crons honor a
CRON_TZvariable; don't assume yours does. - A bare-minimum environment. Cron jobs get a tiny
PATHand none of your shell config. That's whycurlworks in your terminal but "command not found" in cron. Use absolute paths in the command. - Names are a portability trap.
JANandMONwork in the month and day-of-week fields on many crons — but ranges and lists of names aren't portable.MON-FRIwill fail somewhere. Numbers always work.
In a crontab file, a bare % in the command is treated as a
newline, and everything after it is fed to the command as stdin. So
date +%F in a crontab is not doing what you think. Escape it:
date +\%F. This one silently corrupts scripts — it doesn't
error, it just misbehaves.
Verify before you deploy
The whole reason the cron builder exists is that reading cron in your head is exactly the skill you shouldn't trust. Paste an expression and you get two things: a plain-English sentence, and the next five run times — computed with the correct OR rule, not the one you remember.
Before anything goes to production, check the next five runs against your intent. Especially around the 1st of the month, and around DST changes — a 02:30 job has a bad night twice a year.
And if a freshly scheduled job starts throwing 401s at odd hours, that's a different problem — we have a post on that one too.
Wrap-up
Five fields, four operators, one OR rule. Say the schedule in English, fill the fields left to right, and verify with the next-runs preview before it's live. That's the whole language.
Next up: MD5 vs SHA-256 — when each one is fine, for everyone who's ever checksummed a download and wondered whether it mattered. And if you write a cron expression that still surprises you, paste it into the builder — watching the English sentence disagree with you is the fastest way to learn.