OnlineTextLab
Esc

Cron Expressions Explained: Fields, Steps, and the OR Rule

Updated 7 min read How we check our guides

A standard cron expression has five fields, and a job runs whenever the current time matches them all, with one big exception: if you restrict both day-of-month and day-of-week, the job runs when either matches. That single rule is behind most misbehaving schedules. The other common failures come from time zones and from assuming that every system’s “cron” is the same.

The five fields

┌───────── minute        0-59
│ ┌─────── hour          0-23
│ │ ┌───── day of month  1-31
│ │ │ ┌─── month         1-12 or JAN-DEC
│ │ │ │ ┌─ day of week   0-7 or SUN-SAT (0 and 7 are both Sunday)
│ │ │ │ │
* * * * *  command

Each field accepts:

SyntaxMeaningExample
*every value* in hour: every hour
,a list1,15 in day: 1st and 15th
-a range1-5 in weekday: Monday to Friday
/a step*/15 in minute: every 15 minutes

Examples

We checked each of these with the Python croniter library, starting from Tuesday 3 November 2026.

*/15 9-17 * * 1-5     Every 15 minutes, 9:00 to 17:45, Monday to Friday
0 9 * * 1-5           9:00 on weekdays
0 0 * * *             Midnight every day
30 2 * * 0            2:30 every Sunday
0 0 1 * *             Midnight on the 1st of each month
0 0 1 1 *             Midnight on 1 January
0 */6 * * *           00:00, 06:00, 12:00, 18:00
5-59/15 * * * *       :05, :20, :35, :50 of each hour

The range 9-17 includes 17, so that first example fires through 17:45, a detail that’s easy to miss. To stop at 17:00, split it across two lines.

Steps are not intervals

*/n means “every nth value, counted from the start of the field’s range”. It does not mean “every n units since the last run”, and that creates uneven gaps when the step doesn’t divide the range.

*/7 in the minute field, starting from 12:50, produced 12:56, 13:00, 13:07, 13:14 in our test. The gap across the top of the hour is 4 minutes.

*/2 in the day-of-month field starting from 28 December produced 12-29, 12-31, 01-01, 01-03. It runs on odd days, which gives two consecutive days around a 31-day month’s end. For true “every other day” behaviour, keep a timestamp of the last run and have the job check it, or use a scheduler with interval support.

The day-of-month and day-of-week rule

The crontab(5) manual says that if both day fields are restricted, meaning neither is *, “the command will be run when either field matches the current time”. Its own example is 30 4 1,15 * 5, which runs at 4:30 on the 1st, on the 15th, and on every Friday.

So this does not mean what it looks like:

0 9 1-7 * 1

A reader would assume “the first Monday of the month at 9:00”. Here’s what croniter produced from 3 November 2026:

Wed 11-04, Thu 11-05, Fri 11-06, Sat 11-07, Mon 11-09, Mon 11-16, Mon 11-23

That’s the first seven days of the month plus every Monday. Likewise, 0 0 13 * 5 doesn’t mean Friday the 13th. It fires on every Friday and on every 13th.

There’s a quirk in this rule. Vixie cron, which most Linux systems derive from, decides whether a field is restricted by checking whether it starts with *. So */2 in a day field counts as unrestricted, and 0 0 */2 * 1 means odd-numbered days that are also Mondays. Other implementations can differ on this edge, so avoid relying on it.

The usual fix is to restrict one field in cron and test the other in the command:

0 9 1-7 * * [ "$(date +\%u)" = 1 ] && /usr/local/bin/monthly-report

Note the backslash before %. In a crontab, an unescaped % is treated as a line break, and the rest of the command is passed as standard input. This catches everyone exactly once, usually with a date format string.

Time zones and daylight saving

Traditional cron uses the time zone of the machine it runs on. If that’s UTC, 0 9 * * * runs at 9:00 UTC, not 9:00 where your users are, and it shifts by an hour against local time twice a year in regions that observe daylight saving.

The cronie implementation, common on Linux, offers CRON_TZ to set a zone for a crontab, and its manual describes the daylight saving hazard directly. Times in the missing hour during the spring change “will never match”, and times that occur twice in the autumn change cause jobs to run twice. Other cron implementations treat these cases differently, so read the manual for yours.

Our advice:

  • Run servers in UTC, and write schedules in UTC.
  • For a job that must run at a local wall-clock time, set the zone explicitly. Don’t rely on the host.
  • Don’t schedule anything important between 01:00 and 03:00 local time in a zone with daylight saving. A job at 02:30 can be skipped or doubled.
  • Make jobs idempotent, so an extra run is harmless. It also helps with retries.

To work out what a UTC schedule means for a colleague elsewhere, use the Time Zone Converter.

”Cron” is not one format

Copying an expression between systems is a frequent cause of silent failures.

SystemFieldsTime zoneNotes
Linux crontab5Host, or CRON_TZ in cronieThe standard described above
GitHub Actions5UTC by default, optional timezone keyShortest interval 5 minutes. Runs can be delayed under load, especially at the top of the hour
Kubernetes CronJob5Controller’s zone, or .spec.timeZoneStable since v1.27. Use concurrencyPolicy to control overlapping runs
AWS EventBridge rules6, with yearUTCMust use ? in one day field. Weekday is 1-7 starting at Sunday. Scheduler is the newer AWS service
Quartz (Java)6 or 7ConfigurableSeconds field first, and ? is required in one day field

The AWS row has the trap most likely to cost you. cron(0 12 * * 1 *) isn’t valid at all, and 1 in its day-of-week field means Sunday, while in crontab 1 means Monday. GitHub’s documentation also says @daily-style shortcuts are unsupported in workflows. Those shortcuts are an extension in some cron implementations anyway, so stick to the five fields for portability.

Using our Cron Expression Explainer

Our Cron Expression Explainer takes a five-field expression, says in words what it means, breaks down each field, and lists upcoming runs in your time zone or in UTC. It follows Vixie cron’s rules: names such as MON-FRI work, 7 is Sunday, shortcuts such as @daily are expanded, and for 0 0 13 * 5 it says “on the 13th of the month or on Friday” and warns about the OR rule. It’s a fast way to catch a misread schedule before it runs.

It doesn’t cover the other dialects in the table above. Six and seven-field Quartz and AWS expressions are rejected, with a message saying why. And it computes dates from the rules, not from your server, so it knows nothing about your scheduler’s own time zone or how it handles daylight saving. Confirm with the system that will run the job, and watch the first few runs.

Operational traps

Environment. Cron runs jobs with a minimal environment and a short PATH. A script that works in your shell fails with “command not found”. Use absolute paths, or set PATH at the top of the crontab.

Overlap. Cron doesn’t know whether the previous run finished. If a job takes 20 minutes and runs every 15, copies pile up. On Linux, use flock:

*/15 * * * * flock -n /tmp/sync.lock /usr/local/bin/sync

Silent failure. By default, output is emailed to the local user, which often means nowhere. Redirect output to a log, and use an external check that alerts when a job didn’t run, which cron can’t tell you.

Many jobs at once. Everyone writes 0 0 * * *. Pick a less obvious minute, such as 17 3 * * *, so you don’t hit shared systems at the same moment as every other job. GitHub’s documentation gives the same advice for its scheduler.