Cron expressions, field by field: a practical guide with real examples
Five little fields control almost every scheduled job on the internet. Here's how to read and write them without guessing.
Somewhere in your infrastructure, something runs on a schedule: a nightly
backup, an hourly cache warm, a weekly report, a "clean up stale sessions every
fifteen minutes" job. Odds are overwhelming that the schedule is written as a
cron expression — five terse fields that have driven Unix scheduling since
the 1970s and now power everything from Linux crontab to Kubernetes CronJobs
to GitHub Actions to serverless triggers.
Cron syntax is compact to the point of being cryptic. */15 9-17 * * 1-5 is
completely opaque until someone explains the grammar — and then it's obvious.
Let's make it obvious.
The five fields
A standard cron expression is five fields separated by spaces:
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 6, Sunday = 0)
│ │ │ │ │
* * * * *
Read left to right: minute, hour, day-of-month, month, day-of-week. Each field
answers "at which values of this unit should the job fire?" A * means "every
value." So * * * * * means every minute of every hour of every day — the job
runs 1,440 times a day.
The four operators
Every field understands the same four operators, and once you know them you can read any expression.
* — every value. * in the hour field means "every hour."
A specific number. 0 in the minute field means "at minute zero" (the top of
the hour). 30 3 * * * runs at 03:30 every day.
A list with commas. 0,15,30,45 in the minute field means "at 0, 15, 30, and
45 minutes past." 0 9,12,17 * * * runs at 9am, noon, and 5pm.
A range with a hyphen. 1-5 in the day-of-week field means Monday through
Friday. 0 9-17 * * * runs at the top of every hour from 9am to 5pm.
A step with a slash. */15 in the minute field means "every 15th value" —
i.e., 0, 15, 30, 45. You can combine steps with ranges: 0-30/10 means 0, 10,
20, 30. Steps are the operator people forget exists, and they're the cleanest way
to express "every N units."
Reading real expressions
Now the earlier example decodes cleanly:
*/15 9-17 * * 1-5
Every 15 minutes, during hours 9 through 17, on days of the week Monday through Friday. In plain English: every 15 minutes, 9am–5:45pm, on weekdays. A business-hours polling job.
A few more you'll recognize once you can read them:
| Expression | Meaning |
|---|---|
0 0 * * * | Every day at midnight |
0 */6 * * * | Every 6 hours (00:00, 06:00, 12:00, 18:00) |
0 9 * * 1 | 9am every Monday |
30 2 1 * * | 02:30 on the 1st of every month |
0 0 * * 0 | Midnight every Sunday |
15 14 1 * * | 14:15 on the 1st of every month |
0 22 * * 1-5 | 10pm on weekdays |
The day-of-month / day-of-week gotcha
Here's the rule that surprises almost everyone. When you specify both
day-of-month and day-of-week (neither is *), most cron implementations treat
them as an OR, not an AND.
0 0 1,15 * 1
You might read that as "midnight on the 1st and 15th, but only if it's a Monday." It actually means "midnight on the 1st, and the 15th, and every Monday." The job fires on any day that matches either condition. If you need a true "the 15th only when it's a Monday," cron can't express it directly — you check the extra condition inside the job itself.
Special strings and non-standard fields
Many cron implementations accept convenient shorthand macros:
@yearly(or@annually) —0 0 1 1 *@monthly—0 0 1 * *@weekly—0 0 * * 0@daily(or@midnight) —0 0 * * *@hourly—0 * * * *@reboot— run once at startup
Some flavors add a sixth field for seconds at the front (common in Quartz,
Spring, and many programming-language cron libraries), so */30 * * * * * means
"every 30 seconds." And some support L for "last" (last day of month),
W for "nearest weekday," and # for "the Nth weekday" (5#3 = the third
Friday). These are extensions — always check what your specific scheduler
supports rather than assuming.
The timezone trap
Cron traditionally runs in the server's local timezone (or historically,
UTC, or whatever TZ is set to). This is a classic source of "the report went
out at the wrong time" bugs, especially around daylight saving transitions, when
a local-time schedule can either skip an hour or run a job twice. Two defensive
habits: pin your scheduler to UTC and do timezone math inside the job, or
explicitly configure the timezone your platform offers (Kubernetes CronJobs, for
instance, added a timeZone field for exactly this reason). Either way, know
which timezone your cron is interpreting, because the expression itself carries no
timezone information.
Sanity-checking before you deploy
The failure mode with cron isn't usually a syntax error — it's a schedule that
parses fine but doesn't mean what you intended. 0 0 * * * and 0 * * * * differ
by a single character and by a factor of 24 in how often they fire. Before you
commit a schedule, it's worth translating it back into English and checking the
next few run times. A Cron Expression Explainer does
exactly that: paste the expression, read a plain-language description, and confirm
it lines up with what you meant before it ships to production.
The takeaway
Cron is five fields — minute, hour, day-of-month, month, day-of-week — and four
operators: * for all, a number for one, commas for a list, hyphens for a range,
and slashes for steps. The two things that bite even experienced engineers are the
day-of-month/day-of-week OR behavior and the timezone the scheduler interprets.
Keep those in mind, translate any expression back to plain English before you
trust it, and cron stops being cryptic.