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.

In this post
  • 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:

anatomy.txt
# ┌────────── 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 7 when you see one.

The four operators

Every field speaks the same small language:

operators.txt
*       # 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:

real-world.txt
*/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:

the-trap.txt
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.

The OR rule

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.

So how DO you run "first Monday"?

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:

macros.txt
@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_TZ variable; don't assume yours does.
  • A bare-minimum environment. Cron jobs get a tiny PATH and none of your shell config. That's why curl works in your terminal but "command not found" in cron. Use absolute paths in the command.
  • Names are a portability trap. JAN and MON work in the month and day-of-week fields on many crons — but ranges and lists of names aren't portable. MON-FRI will fail somewhere. Numbers always work.
The percent sign eats your script

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.