Skip to content
Allin

How to Write a Cron Expression: Five Fields and the OR Rule Nobody Mentions

Published 5/21/2026 · 9 min read · Developer tools

Daniel Okonkwo

Daniel OkonkwoFront-end developer and tech writer at Allin

Web performance · File formats

Checked against 2 sources

View profile
In short

A cron expression is five whitespace-separated fields read left to right: minute (0-59), hour (0-23), day of month (1-31), month (1-12 or three-letter names), and day of week (0-6 in POSIX with 0 as Sunday, extended to 0-7 by most implementations so that 7 is Sunday too). An asterisk means every value in that field's range. A hyphen makes an inclusive range, so 8-11 in the hour field is 8, 9, 10 and 11. A comma makes a list, and lists and ranges can be mixed: 1-3,7-9. A slash adds a step, and this is the first place people go wrong — a step strides through a range rather than setting an interval, so */15 in the minute field means 0, 15, 30 and 45, and */40 means 0 and 40 and then nothing until the next hour restarts the range. The second and larger trap is that day of month and day of week are combined with OR, not AND. If both are restricted, the job runs when either one matches. So 0 0 1 * 1 does not mean the first Monday of the month; it fires at midnight on the 1st of every month AND at midnight on every Monday. To pin a job to a specific weekday, leave day of month as an asterisk and vice versa. Everything else you will see — @daily, a sixth seconds field, the ? character, L, W and # — is outside the standard and depends entirely on which cron you are running.

Minute, hour, day of month, month, day of week. The traps are that a step is a stride through a range and not an interval, and that the two day fields are combined with OR — so 0 0 1 * 1 fires on the 1st and on every Monday.

Five fields, and a step is not an interval

Read the fields left to right and the notation is small enough to hold in your head. An asterisk stands for the whole range of its field. A hyphen makes an inclusive range, so 8-11 in the hour field is four hours, not three. A comma makes a list, and lists and ranges combine freely in the same field, so 1-3,7-9 is a legal set of six values. Names may replace numbers in the month and day-of-week fields using the first three letters, though classic Vixie cron will not accept a range or a list built from names — 1-3 parses where JAN-MAR may not, which is a good reason to stick to numbers in anything portable.

The slash is where the mental model usually breaks. A step is a stride through a range, not a repeat interval, and the range restarts at the top of every parent unit. In the minute field */15 walks 0, 15, 30, 45 and then the hour rolls over and it starts again at 0 — which happens to be every fifteen minutes, so nobody notices the distinction. Write */40 and the illusion collapses: it walks 0 and 40, then the hour rolls over and it starts again at 0, giving gaps of forty minutes and then twenty. The same applies to a bounded range: 0-23/2 in the hour field is the even hours, and 1-9/2 is 1, 3, 5, 7 and 9. If you actually need an interval that does not divide its unit evenly, cron cannot express it in one line and you need two entries or a guard inside the command.

The two day fields are combined with OR, not AND

This is the rule most explainers get wrong, and it is not a quirk of one implementation — it is written into the specification. POSIX states that if the day of month is given as an element or list and the day of week is also given as an element or list, then any day matching either the day of month or the day of week shall be matched. The crontab(5) manual says the same thing in plainer words and gives the canonical example: 30 4 1,15 * 5 runs at 4:30 in the morning on the 1st and the 15th of each month, plus every Friday. Not the 1st or 15th when they happen to fall on a Friday. Both sets, unioned.

The practical rules that follow are short. If exactly one of the two day fields is an asterisk, the other one governs and there is no ambiguity — this is the case you should write almost every time. If both are asterisks, every day matches, which is also unambiguous. Only when both are restricted does the union kick in, and the result is almost never what the author intended: 0 0 1 * 1 fires roughly five times a month rather than once. There is no cron syntax for the intersection, so if you genuinely need the first Monday of the month, the standard trick is to schedule the union and then narrow it inside the command — run it on 0 0 1-7 * 1 and open the script with a test that the current day of week is Monday, or on 0 0 * * 1 with a test that the day of month is 7 or less. Quartz-style crons add a # operator for this, but it is not available to system cron.

Everything past the five fields is implementation-specific

POSIX defines five fields, the asterisk, ranges, lists and nothing else. Every convenience you have seen beyond that is an extension, and extensions vary. The slash step is a Vixie extension that is now near-universal but is still not in the standard. The shorthand strings — @yearly and its synonym @annually, @monthly, @weekly, @daily and its synonym @midnight, @hourly, and @reboot — come from the same lineage, and @reboot in particular has no fixed meaning across systems because what counts as a reboot depends on the daemon. A sixth field for seconds is common in application-level schedulers such as Quartz, Spring and several Node libraries, and absent from system cron, so an expression copied from a framework's documentation into a crontab will be off by one field and will schedule something entirely different rather than failing loudly.

The question mark belongs to the same family. In Quartz it means no specific value and exists precisely to resolve the day-of-month versus day-of-week ambiguity by declaring one field irrelevant; system cron does not accept it at all. The L, W and # operators for last day of month, nearest weekday and nth weekday are likewise Quartz-only. Two more environmental facts matter more than any of this syntax: the daemon runs jobs in the timezone it is configured for, so an expression that is correct in one deployment can fire an hour early or late in another, and around a daylight-saving transition a job scheduled in the skipped hour may not run at all while one in the repeated hour may run twice. And cron is a trigger, not a job runner — it has no retries, no concurrency control and no memory that the last run failed. If two invocations must not overlap, take a lock inside the command yourself.

The five fields, in order, with the mistake each one invites
PositionFieldAllowed valuesThe mistake it invites
1stMinute0-59Writing * here when you meant 0 — an expression like * 3 * * * runs sixty times, once every minute of the 3 a.m. hour
2ndHour0-23Reaching for a 12-hour clock: there is no 24 and no pm, midnight is 0 and 11 p.m. is 23
3rdDay of month1-31Using 31 and expecting twelve runs a year: months that are shorter simply never match, so the job silently skips February, April, June, September and November
4thMonth1-12, or JAN through DECAssuming names work everywhere: classic Vixie cron accepts three-letter names but rejects ranges or lists of them, so JAN-MAR may not parse where 1-3 does
5thDay of week0-6 in POSIX with 0 as Sunday; 0-7 in most implementations, where 7 is Sunday tooRestricting this field and the day of month at the same time: the two are OR'd, so the job fires on both sets of days instead of their intersection
Cron expression generatorBuild a cron schedule field by field — minute, hour, day of month, month, weekday — and see the expression and a plain-language description update live. One-click presets cover the common schedules, and the syntax (*, */5, 1-5, 1,15) is explained inline.Try the tool

Frequently asked questions

What exactly does */15 mean?
In the minute field it selects the values 0, 15, 30 and 45 — a stride of fifteen through the range 0 to 59, starting at the range's first value. It is not a timer that fires fifteen minutes after whenever it last ran, and it does not survive an unclean division: */40 in the minute field selects only 0 and 40, so the gaps are forty minutes and then twenty as the next hour restarts the range. If you want an interval measured from the last run rather than from the top of the hour, cron is the wrong tool and a systemd timer with OnUnitActiveSec, or a loop inside a long-lived process, is the right one.
How do I run a job every 90 minutes?
Not in one line, because 90 does not divide 60 and a step is confined to a single field. The pattern does repeat every three hours, though, so two entries cover it exactly: 0 */3 * * * gives you 00:00, 03:00, 06:00 and so on, and 30 1-23/3 * * * gives you 01:30, 04:30, 07:30 and so on. Together they produce a run every ninety minutes, and the sequence wraps cleanly at midnight because twenty-four hours is exactly sixteen ninety-minute intervals. The same two-entry trick works for any interval that divides evenly into a whole number of hours; anything that does not, such as every 50 minutes, cannot be expressed in cron at all.
Why did my nightly job run twice, or not at all?
The usual cause is a daylight-saving transition. Cron matches wall-clock time, so when the clock jumps forward an hour that never existed is never matched and a job scheduled inside it simply does not run; when the clock falls back, an hour occurs twice and a job scheduled inside it can fire twice. Implementations differ in how hard they try to compensate, which is why the same crontab behaves differently on two distributions. The reliable fixes are to schedule sensitive jobs outside the transition window, to run the daemon in a timezone that has no transitions, or to make the command itself safe to run twice. That last one is worth doing regardless: idempotence protects you from retries, overlapping runs and manual re-execution as well.

Articles you may find interesting

All guides
ExplainerHow Unix File Permissions Work: Reading 755 Without GuessingRead is 4, write is 2, execute is 1, and each of the three digits describes a different party. The part most explanations get wrong is what the execute bit does on a directory — it grants traversal, not the right to run anything.GuideHTTP Status Codes Explained: The Ones That Actually Get Confused301 against 308, 302 against 307, 401 against 403, 404 against 410 — plus what Retry-After on a 429 or a 503 actually promises. The pairs where picking the wrong code changes behaviour, not just wording.ExplainerWhat Is a Cron Expression?A cron expression schedules a task to run automatically at set times. Here's what it's for, its five fields, how to read one, and the common gotchas.How-toHow to Write a robots.txt: Directives, Matching, and What It Cannot HideFour directives, two wildcards, one file at the host root. It is a crawl instruction and nothing more — it does not remove a page from search results, it does not restrict access, and it publishes every path you list in it.ExplainerWhat an Availability Percentage Actually AllowsThree nines sounds like a promise until you divide it into minutes. What 99.9 % buys per year, per month, per week and per day; why the measurement window matters far more than the extra nine; and the two different months this tool uses for the same slug.ExplainerThe Golden Hour and the Blue Hour Are Angles, Not HoursGolden hour runs from +6° to −4° of solar elevation and blue hour from −4° to −6°, which is why it lasts forty minutes at the equator, over an hour at mid-latitudes, and above 72.6° in June does not happen at all. The thresholds this calculator uses, checked against its own output.

Related tools

Sources

Spotted a mistake in this article?