Cron Expression Parser, Explained

After reading this you can read any 5-field cron expression at a glance, predict its next runs by hand, and avoid the three scheduling bugs that fire jobs at the wrong time.

What a cron expression is

A cron expression is a compact schedule. It has five fields separated by spaces: minute, hour, day-of-month, month, and day-of-week. A daemon reads the fields once a minute and runs the job when all of them match the current time.

Here is the hook. Read this line:

0 \ 9 \ * \ * \ 1\text{-}5

Left to right: minute 0, hour 9, any day-of-month (*), any month (*), day-of-week 1-5 (Monday through Friday). So it means "at 09:00 on weekdays." One line, five numbers, one clear meaning. The trouble starts when the fields interact, which is why a parser that previews real timestamps beats reading the syntax in your head.

When to use it, and when a real scheduler is better

Reach for cron when a task repeats on a wall-clock pattern: nightly backups, hourly cache warmups, a weekly report at 07:00 Monday. Cron is stateless. It does not track whether a run succeeded, and it does not retry. It just checks the clock every minute.

Do not use cron for these cases. If you need a job to run 30 minutes after another finishes, cron cannot express that; it only knows absolute times. If runs must not overlap, cron will happily start a second copy while the first is still going. And if you need sub-minute timing, cron cannot help: the smallest field is one minute.

The classic cron has 5 fields. Some systems add a 6th leading field for seconds (Quartz, some Go and Java libraries) or a trailing year field. This parser uses the standard 5-field POSIX form. If your expression has 6 fields, drop the seconds field before pasting it.

The five fields and their ranges

Each field accepts a number, a range, a list, a step, or the wildcard *. The valid ranges are fixed:

Field positions, allowed values, and names
PositionFieldRangeNames allowed
1minute0-59none
2hour0-23none
3day-of-month1-31none
4month1-12JAN-DEC
5day-of-week0-7SUN-SAT

Day-of-week is the one to watch. Both 0 and 7 mean Sunday, so the field really covers 8 tokens for 7 days. Monday is 1, Saturday is 6.

The four operators

Wildcard *
Every value in the field's range. In the minute field it means all 60 minutes.
List a,b,c
Any of the listed values. 0,30 in minutes means minute 0 and minute 30.
Range a-b
Every value from a to b inclusive. 1-5 in day-of-week is Monday through Friday.
Step a/n or */n
Every nth value. */15 in minutes is 0, 15, 30, 45. 10-30/5 is 10, 15, 20, 25, 30.

How the next run is computed

To find the next fire time, walk forward from "now" one minute at a time and stop at the first minute where the match condition holds. The condition is a logical AND across four of the five fields, with one exception:

\text{match} = M_{min} \land M_{hour} \land M_{mon} \land (M_{dom} \lor M_{dow})

Here M_{min} is true when the current minute is in the minute field's set, and likewise for hour, month, day-of-month, and day-of-week. Minute, hour, and month are AND-ed. Day-of-month and day-of-week are combined with OR when both are restricted, which is the POSIX quirk explained below.

The step operator expands to a set before matching. For */15 the minute set is \{0, 15, 30, 45\}, computed as every value v in 0-59 where v \bmod 15 = 0. A cron with all-wildcard fields (* * * * *) matches every minute, so it fires 1440 times a day.

With minute field 0, hour 9, and day-of-week 1-5, the next five runs from Monday 2024-01-01 08:00 are 09:00 on Jan 1, 2, 3, 4, and 5, then skipping the weekend to Jan 8. A step of */15 in the minute field instead fires at 09:00, 09:15, 09:30, and 09:45.

Worked example: reproducing the demo

The demo loads the field defaults, which form the expression 0 9 * * 1-5. Find its next runs starting from Monday, 1 January 2024 at 08:00 server time.

  1. Minute must be 0. Hour must be 9. Both month and day-of-month are *, so they match every value.
  2. Day-of-week is 1-5, the set \{1,2,3,4,5\} (Mon-Fri). Day-of-month is *, so only one of the pair is restricted and the OR quirk does not bite. Effectively the day-of-week filter applies.
  3. Scan forward from 08:00 on Monday. The first minute where minute=0, hour=9, and the day is a weekday is 09:00 that Monday, 2024-01-01.
  4. The next matches are 09:00 on Jan 2 (Tue), Jan 3 (Wed), Jan 4 (Thu), Jan 5 (Fri).
  5. Jan 6 and 7 are Saturday and Sunday. Day-of-week 6 and 0 are not in \{1,2,3,4,5\}, so both are skipped. The sixth run is 09:00 on Jan 8 (Mon).

Five runs a week, one a day, always at 09:00. Over a 31-day January with 23 weekdays, that is 23 fires.

Reading and interpreting the results

The parser gives you two things: a field-by-field English gloss, and a list of upcoming timestamps. Read the gloss first to confirm the intent, then check the timestamps to confirm the effect. They should agree. If the gloss says "every 15 minutes" but the runs are an hour apart, you have a typo, probably a comma where you meant a slash.

Count the runs. A schedule that should fire hourly (0 * * * *) produces 24 timestamps per day. If you see 1440, you left the minute field as * by mistake, and the job runs every minute. The run count is the fastest sanity check you have.

Read step values as a frequency, not an offset. */15 means "4 times an hour at 0, 15, 30, 45," not "15 minutes after the hour." If you want a single run at :15, write 15, not */15.

Common mistakes

The day-of-month and day-of-week OR is the mistake that surprises everyone. When you restrict both fields, cron fires when either matches, not both. Take 0 0 13 * 5. You might read it as "midnight on Friday the 13th." It actually means midnight on the 13th of every month AND midnight on every Friday. In a normal month that is roughly 4 Fridays plus one 13th, so around 5 fires, not the 1 or 2 you expected. To get only Friday the 13th you cannot express it in standard cron alone; you run a daily job and check the date inside the script.

The second mistake is timezone. Cron runs in the server's timezone. A job at 0 9 * * * fires at 09:00 on the server clock. If your laptop is in a zone 6 hours behind the server, that run lands at 03:00 your time. Confirm the server's zone before you trust a morning schedule.

The third is the off-by-one in day-of-week. Sunday is both 0 and 7, and Monday is 1. People coming from languages where Monday is 0 write 0 expecting Monday and accidentally schedule Sunday.

Do not restrict both day fields unless you truly want the OR behavior. If you need "the 1st of the month" alone, leave day-of-week as *: write 0 0 1 * *, never 0 0 1 * 1.

Related tools

Cron fires produce timestamps, and you often need to convert them. To turn a fire time into Unix time or read a log timestamp back into a date, use the Unix Timestamp Converter. If a log gives you a bare number and you cannot tell whether it is seconds or milliseconds, the Epoch Millis vs Seconds Detector settles it. And when a scheduled job needs a unique run ID, the UUID / ULID / Nano ID Generator produces sortable ULIDs and UUID v7 that carry their own timestamp.

Frequently asked questions

What does the asterisk mean in cron?

The asterisk * means "every value in this field." In the minute field it matches all 60 minutes; in the day-of-month field it matches every day. A field of * never narrows the schedule.

How do I run a job every 15 minutes?

Use */15 * * * *. The minute field expands to 0, 15, 30, 45, and the other fields stay wide open, giving 96 runs a day.

Why does my Friday the 13th cron fire on other days?

Because day-of-month and day-of-week are OR-ed when both are set. 0 0 13 * 5 fires on the 13th and on every Friday. Standard cron cannot AND the two fields, so schedule daily and test the date in your script.

Does 0 mean Sunday or Monday?

In the day-of-week field, 0 is Sunday and 7 is also Sunday. Monday is 1. This differs from languages that number Monday as 0.

What timezone does cron use?

The server's local timezone. A job at 0 9 * * * fires at 09:00 server time. Check the machine's zone setting, since it may differ from yours by several hours.