All posts

What Is a Cron Expression? The Five Fields, Explained

7 min read

A cron expression is five fields that describe a time. What each field matches, why */7 gives you nine runs an hour, and when the first of the month fires on a Monday too.

A cron expression is a string of five fields that describe a time. The daemon reading it wakes up once a minute, compares the current time against every expression it holds, and runs the ones that match.

That's the entire mechanism. There's no queue, no counter and no record of the last run. Cron never asks how long it has been since a job fired. It asks whether this minute matches, and the answer is yes or no.

Most of what surprises people about cron comes from that gap. We read expressions as schedules and expect them to measure time. Cron evaluates them as filters over the current minute.

The five fields

Field Values Notes
Minute 0-59
Hour 0-23 24-hour clock, no AM/PM. 9 is 9 AM, 21 is 9 PM
Day of month 1-31
Month 1-12
Day of week 0-7 0 and 7 are both Sunday

Left to right, the smallest unit comes first. 0 9 * * 1-5 reads as "minute 0, hour 9, any day of the month, any month, Monday through Friday", which in English is "9:00 on weekdays". The order is the reverse of how you say a time out loud, and that's the first thing that catches people. Writing 9 0 * * 1-5 is a valid expression. It runs at 9 past midnight.

Four symbols modify the fields:

Symbol Meaning Example
* Any value * in the hour field means every hour
, List 1,15 in day of month means the 1st and the 15th
- Range 1-5 in day of week means Monday to Friday
/ Step */15 in minute means every 15th minute

Those four are the whole vocabulary of a standard expression. The symbols that Quartz adds (?, L, W, #) are a separate notation and are collected on the cron expressions cheat sheet along with the platform differences.

The / operator doesn't count

*/15 in the minute field fires at :00, :15, :30 and :45. Not fifteen minutes after the job last ran, and not fifteen minutes after you saved the file. The step is applied to the field's own range, so the matching minutes are the ones divisible by 15, and the minute after :45 is :00 of the next hour.

That distinction gets more visible with a step that doesn't divide 60. */7 matches :00, :07, :14, :21, :28, :35, :42, :49 and :56, and then starts over:

*/7 * * * *   →   :00 :07 :14 :21 :28 :35 :42 :49 :56 :00 :07 ...
                                                     ^
                                                four minutes, not seven

Nine runs an hour, with a four-minute gap where the seventh-minute cadence meets the hour boundary. If you need an even interval, use a step that divides 60 cleanly (*/5, */10, */15, */20, */30). Otherwise the seam is part of the schedule and you should know where it lands.

The same applies to hours. 0 */6 * * * runs at 00:00, 06:00, 12:00 and 18:00. Restart the server at 04:00 and the next run is at 06:00, not six hours later.

Why the first of the month can fire on a Monday

Day of month and day of week are the two fields people misread. The POSIX specification for crontab is explicit about the case where both are restricted: the command shall be run on any day matching either field.

So 0 0 1 * 1 doesn't mean "the first of the month if it happens to be a Monday". It means midnight on the 1st, and midnight on every Monday:

2026-01-01   first of the month
2026-01-05   Monday
2026-01-12   Monday
2026-01-19   Monday
2026-01-26   Monday
2026-02-01   first of the month
2026-02-02   Monday

Two runs on consecutive days, and 2026 has five pairs like that. Across the year the expression fires 63 times: the 12 firsts of the month plus the 52 Mondays, minus the one day that is both, which in 2026 is 1 June. If a job that runs with a 1 in the day-of-month field shows up on days you didn't plan for, this rule is usually why.

The rule only applies when both fields are restricted. As soon as one of them is *, the * matches everything and the other field alone decides. 0 0 1 * * is just the first of the month.

Names, aliases and @reboot

Vixie cron, the implementation on essentially every Linux distribution, accepts three-letter names where the numbers normally go: MON-FRI in day of week, JAN-DEC in month. It also accepts a set of shorthand words that replace an expression entirely:

Alias Equivalent
@hourly 0 * * * *
@daily, @midnight 0 0 * * *
@weekly 0 0 * * 0
@monthly 0 0 1 * *
@yearly, @annually 0 0 1 1 *
@reboot no schedule at all

None of this is in POSIX. It works on Linux and on macOS and it will not work on a BusyBox container or a scheduler that follows the standard strictly.

@reboot is the odd one in that list because it isn't a time. The job runs when the daemon starts, which on a machine that reboots nightly means it runs nightly, and on a machine with months of uptime means it runs once. It's the clearest example of cron having no concept of a job's history: there's no "since last run" for it to attach to.

Six fields means you're not in cron

If a scheduler rejects your five fields and demands six, you're not looking at cron. Quartz (Java), Spring's @Scheduled and most cron libraries derived from them put seconds in the first position, shifting everything right. AWS EventBridge keeps the five POSIX fields where they are and appends a year, which is a different six. The rest of what those platforms do differently, from day numbering to the ? AWS insists on, is in cron syntax across platforms.

There's a third six-field format that looks closer to home: /etc/crontab and the files under /etc/cron.d have a user column between the schedule and the command. The expression is still the five fields you already know, and the extra column is which account runs the job. Copy a line out of your own crontab into /etc/cron.d without adding that column and cron has nothing to run it as, so the line doesn't take effect.

Where the expression lives

Where Fields Notes
crontab -e 5 Per user, no user column
/etc/crontab, /etc/cron.d/* 5 + user System-wide
GitHub Actions 5 POSIX, UTC, minimum interval 5 minutes
Kubernetes CronJob 5 The kube-controller-manager's own zone, UTC on most clusters; timeZone per job overrides it

GitHub Actions and Kubernetes both keep the five fields, and both run in UTC by default, which is the part that catches people moving a job out of a crontab. Kubernetes adds its own behaviour on top: concurrencyPolicy defaults to Allow, so a job that overruns its interval starts again alongside the run that is still going. Set it to Forbid and the overlapping run is skipped instead, which is a policy decision the crontab never makes for you.

Checking an expression before you save it

Every rule above is easier to trust by working the next few run times out than by reading the fields a second time. Cron doesn't tell you what it will do; it just does it, once a minute, and the log line is the first warning you get.

The cron parser on this site lays the five fields out separately and reads the expression back in plain English, which is often enough to catch a field you took for a wildcard before the job goes anywhere near a server. If you're chasing a job that runs but not when you expect, the production side of this is covered in why cron schedules don't run when you think they do.

The expressions themselves, grouped by how often they run and with the near-misses called out, are in cron expression examples. The file an expression lives in, and the environment the daemon hands the command, are in the crontab file explained.

A cron expression is a filter, not a plan. Write what you want the current minute to match, and read it back one field at a time.

OCMA Tools

Free developer tools. Most features run client-side, your data stays in your browser. Optional accounts unlock extra features.

Most tools run client-side

© 2026 OCMA Tools — Free developer tools

built for developers, by developers