Cron Expression Simulator
What a cron expression means, the next runs in any timezone, and what it does when the clocks change.
reading…
The two day fields are OR, and the library that describes them says “and”
Put a value in both the day-of-month field and the day-of-week field and cron runs
the job when either matches — not both. It is the one rule in
this format that reverses the answer rather than shifting it, and it is documented
in a single sentence of man 5 crontab that nobody has read:
“If both fields are restricted (ie, are not *), the command
will be run when either field matches the current time. For example,
30 4 1,15 * 5 would cause a command to be run at 4:30 am on the 1st and
15th of each month, plus every Friday.”
So 0 0 13 * 5 is not midnight on Friday the 13th. Starting 1 April 2026
— a month whose 13th is a Monday, so the natural reading predicts no runs at
all — it fires five times:
| Run | Why it matched |
|---|---|
| Fri 3 April | day of week |
| Fri 10 April | day of week |
| Mon 13 April | day of month |
| Fri 17 April | day of week |
| Fri 24 April | day of week |
The reason this is worth a page rather than a footnote is that the library this page
writes its own descriptions with reads it as an AND. cronstrue 3.24.0
describes 0 0 13 * 5 as “At 12:00 AM, on day 13 of the month, and on
Friday” — and gives the manual’s own example,
30 4 1,15 * 5, the same treatment. “And” is the
natural-language reading of a conjunction, and the behaviour is a disjunction. This
page uses cronstrue too, and overrides it in exactly this case: when
both day fields are restricted, the sentence above the table is written here, with
the word or in it.
What happens on the two days a year the clocks move
A cron expression is a pattern over a wall clock, and twice a year a wall clock stops being a reliable index of time. On the spring transition some readings do not exist; on the autumn one some happen twice. What a scheduler does about that is a decision, not arithmetic, and the honest thing a simulator can do is show the transition rather than quietly pick a side.
The behaviour modelled here is the one cronie’s crontab(5)
documents: non-existent times “will never match, causing jobs scheduled
during the missing times not to be run”, and times that occur more than
once “will cause matching jobs to be run twice”. In
America/New_York, that is:
| Expression | Sun 8 March 2026 — 02:00 does not exist |
|---|---|
30 2 * * * | does not run at all; the next run is 02:30 on the 9th |
*/15 2 * * * | all four runs skipped — 02:00, 02:15, 02:30, 02:45 |
0 * * * * | 01:00, then 03:00 — the 02:00 run never happens, so the day has 23 of them |
| Expression | Sun 1 November 2026 — 01:00 happens twice |
|---|---|
0 * * * * | runs twice at 01:00 — once at 05:00Z, once at 06:00Z |
30 1 * * * | runs twice at 01:30 — 05:30Z and 06:30Z |
Those rows are what the table at the top of this page renders, marked inline, for
any expression and any of the IANA zones your browser knows. They are also where
this page and the JavaScript cron library it is tested against part company on purpose:
cron-parser 5.10.0 moves the vanished 02:30 run to 03:30 and fires the
daily 01:30 job once rather than twice. Both are reasonable choices for a scheduler.
Neither is what an unconfigured cron does.
On the cron macOS ships, the better behaviour exists and is switched off.
man 8 cron documents a -s flag enabling “special
handling of situations when the GMT offset of the local timezone changes”,
under which a job caught in a vanished interval runs “at the same absolute
point of time as they would be in the old time zone” — and
-o, which disables it “to be compatible with the old
(default) behavior”. Default means off, and
/System/Library/LaunchDaemons/com.vix.cron.plist launches
/usr/sbin/cron with no arguments. So a simulator that prints
03:30 for that job, with no note, has answered a question about a flag
it never asked you about. The manual is blunter than either of us:
“In general, it is not a good idea to schedule jobs during this
period.”
Steps restart; they do not repeat
A step is a filter over a field’s range, not an interval, and the range restarts at every boundary. That makes a whole family of expressions mean something other than what they read like, and the gap column above is how you see it without taking anyone’s word:
| You write | It reads like | It actually fires at |
|---|---|---|
*/45 * * * * | every 45 minutes | :00 and :45 — gaps of 45 minutes and 15 |
*/61 * * * * | every 61 minutes | :00 only — it is hourly |
0-59/13 * * * * | every 13 minutes | :00 :13 :26 :39 :52 — then an 8-minute gap |
0 */5 * * * | every 5 hours | 00 05 10 15 20 — then a 4-hour gap |
0 0 */7 * * | every 7 days | the 1st, 8th, 15th, 22nd, 29th — then the 1st again |
cronstrue 3.24.0 calls the first two “Every 45 minutes” and
“Every 61 minutes”. Where a step does not divide
its field evenly this page adds a line saying so and naming the short gap; where it
does — */15, */20, 0 */2 * * * —
it says nothing, because there is nothing to warn about.
Input this page refuses, and why refusing is the feature
The two worst inputs are the ones that look like nothing at all. Measured against
cron-parser 5.10.0: the empty string
parses, and schedules something every minute; three fields parse
too, read as seconds, minutes and hours. That is how a truncated template variable or
a half-finished edit becomes a job that runs 1,440 times a day, and it is why this
page counts the fields before it does anything else and names the ones that are
missing.
The other half of the same principle is that a refusal has to say what is wrong, at
the character it is wrong at. A caret under the offending field beats a sentence:
five fields on one line is exactly the case where pointing is clearer than prose. So
0 0 * * 8 gets “8 is outside the day of week range 0–7. 0
and 7 are both Sunday” with the caret under the 8, and
0 22-2 * * * gets told that cron ranges do not wrap, along with the
22-23,0-2 that does what was meant.
Two refusals are deliberately not errors. @reboot is a valid crontab
entry with no schedule in it, so it gets the answer rather than a message about
months; 15W — the nearest weekday to the 15th — is named as
the Quartz extension it is, rather than reported as a syntax error, because knowing
whose syntax you have written is the thing you needed. L,
L-3, 5L and 5#3 all work here and are marked
as the same kind of extension: Java schedulers have them, POSIX cron does not.
The sparsest schedule the format can express
0 0 * 2 5#5 is the fifth Friday of February. February has a fifth Friday
only when it has 29 days and the 29th is a Friday, which happens in 2036,
2064, 2092, 2104 — and then, across the century, not again between 2188 and
2228. A forty-year gap, from an expression that is fourteen
characters long and perfectly legal.
That number is why the search behind this page walks a hundred years rather than the
four a leap-day schedule seems to need. It can afford to because the day fields
contain no timezone: the three of them are pure calendar arithmetic, so the search
steps integer day numbers and only asks the browser for a UTC offset once a day
actually matches. A century of days costs about a millisecond. Stepping minute by
minute over the same span, which is the obvious implementation, costs nearly four
seconds. cron-parser finds the 2036 run and then throws
“Invalid expression, loop limit exceeded” reaching for the one
after it.
The neighbouring case is 0 0 29 2 *, which is a real schedule with a
real answer: after 29 February 2096 the next run is 29 February
2104, because 2100 is not a leap year. And
0 0 30 2 * has no run at all, ever — this page says so
immediately, by inspecting the fields rather than by stepping a century to prove a
negative.
The same schedule in four places
# crontab -e. The preamble everyone forgets, and why. # # man 5 crontab documents exactly four variables cron sets for you: SHELL (/bin/sh), # HOME and LOGNAME (from /etc/passwd), and MAILTO (only if it has mail to send). # PATH is not on that list and is not mentioned anywhere in the page — which is why # "it works in my shell but not in cron" is almost always PATH, and why the first two # lines of a working crontab are these: SHELL=/bin/bash PATH=/usr/local/bin:/usr/bin:/bin MAILTO="" # empty means send no mail at all; unset means mail the owner # min hour dom mon dow command 0 9 * * 1-5 /opt/app/bin/report >> /var/log/report.log 2>&1 # A comment may not sit on the same line as a command — it becomes part of the # command. Same for an environment line. Both are in the man page's first paragraph. 0 3 * * 0 /opt/app/bin/vacuum # this comment is an argument to vacuum
# "How do I run cron in another timezone" — the answer on most Linux crons. # # CRON_TZ applies to every line BELOW it in the file, so put it at the top or accept # that the lines above it are still running in the system zone. cronie's crontab(5) # documents it as "the time zone specific for the cron table"; it is an extension, # and the cron macOS ships does not have it — its crontab(5) never mentions a zone. CRON_TZ=Europe/Riga 0 9 * * 1-5 /opt/app/bin/report # 09:00 in Riga, whatever the host is set to # Without it, cron uses the system's local time — so the schedule silently moves when # somebody changes /etc/localtime, and a job written on a laptop lands an hour out on # a server in UTC. The portable version of "run at 09:00 Riga time" on a UTC host is # to do the arithmetic yourself and accept that it breaks twice a year: 0 6 * * 1-5 /opt/app/bin/report # 09:00 EEST — but 08:00 Riga time in winter
# Kubernetes CronJob. Three differences from a crontab, all documented.
apiVersion: batch/v1
kind: CronJob
metadata:
name: report
spec:
schedule: "0 9 * * 1-5"
# 1. Without timeZone, the API reference says the schedule runs in "the time zone of
# the kube-controller-manager process" — not, as is often assumed, UTC. In
# practice that is usually UTC, but it is a property of your control plane rather
# than a guarantee, and naming the zone removes the question.
timeZone: "Europe/Riga"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 300
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: report
image: registry.example.com/report:1.4.2
# 2. The Kubernetes docs give the day-of-week range as 0-6, Sunday to Saturday.
# man 5 crontab gives 0-7, with 0 and 7 both Sunday. "0 0 * * 7" is a valid crontab
# line and is outside the range the CronJob docs describe.
# 3. There is no @reboot. The other seven @ strings are listed, @midnight included.
# systemd timers, which is where a lot of these schedules end up on modern Linux. # Different syntax, and the fields read left to right in calendar order instead of # smallest-unit-first: DayOfWeek Year-Month-Day Hour:Minute:Second. # /etc/systemd/system/report.timer [Unit] Description=Weekday report [Timer] OnCalendar=Mon..Fri *-*-* 09:00:00 Persistent=true # run once on boot if the machine was off when it was due Unit=report.service [Install] WantedBy=timers.target # systemd.time(7) documents shorthands — minutely, hourly, daily, weekly, monthly, # quarterly, semiannually, yearly — and says a calendar event uses local time unless # the expression names a zone or ends in UTC. Check what you wrote before enabling it: # systemd-analyze calendar 'Mon..Fri *-*-* 09:00:00' --iterations=5 # That prints the next five times, which is what the table at the top of this page is.
What this page will not do
It never schedules anything, never notifies, and never keeps state — it is a
simulator, and everything it computes runs in this tab, which you can
check for yourself. It does not parse a whole crontab file
with its environment lines and comments; that is a genuinely useful and genuinely
different tool. It does not accept systemd’s
OnCalendar or Quartz as first-class syntaxes — both appear in the
snippets above and neither is parsed. And it will not tell you when a job last ran:
that is an operations question that needs the machine’s own history, and any
answer computed from the expression alone would be a guess dressed as a record.
The specifications this page follows
man 5 crontab— the five-field format, the 0–7 day-of-week range with 0 and 7 both Sunday, the eight@strings, the three-letter month and day names, and the one sentence defining the day-field OR rule. Also the note that ranges and lists of names are not allowed on this cron, which is whyMON-FRIcarries a portability note here even though Debian and cronie accept it.man 8 cron— the-sand-oflags, which decide what happens at a daylight saving transition and which default to off.- cronie’s
crontab(5)—CRON_TZ, and the sentence that missing times never match while repeated times run twice. That is the behaviour modelled above. - The Quartz extensions —
L,L-3,5L,5#3,?, the seconds field and the year field. Supported here and marked as extensions;WandLWare named and not implemented. - The Kubernetes CronJob API reference —
.spec.timeZone, and the default of “the time zone of the kube-controller-manager process”. systemd.time(7)—OnCalendar, quoted in the snippet above and deliberately not parsed.
FAQ
Why does 0 0 13 * 5 run on every Friday and on the 13th?
Because the two day fields are OR, not AND, and this is the single most surprising rule in the format. It is documented, in one sentence, which is why almost nobody has read it — man 5 crontab: "If both fields are restricted (ie, are not *), the command will be run when either field matches the current time." The manual gives its own example: 30 4 1,15 * 5 runs at 4:30am on the 1st and 15th of each month, plus every Friday. So 0 0 13 * 5 is not "midnight on Friday the 13th" — starting 1 April 2026 it fires on the 3rd, the 10th, the 13th, the 17th and the 24th. Five runs in a month where the natural reading predicts none, because 13 April 2026 is a Monday. The rule only applies when both day fields are restricted: with either one left as *, the other decides alone. And it applies to the day fields only — the month field is still ANDed, so 0 0 29 2 1 runs on February's Mondays and not on Mondays in March. If you want the real "Friday the 13th" schedule, cron cannot express it in one line; the usual answer is 0 0 13 * * with a test on the day of week inside the command.
What does */7 in the day-of-month field actually do?
It fires on the 1st, 8th, 15th, 22nd and 29th of every month, and then restarts — so the gap between the 29th and the next run is three days in January, two in a 30-day month, and one after a leap February. It is not "every 7 days". A step is a filter over a fixed range, not an interval: */7 in a field that runs 1 to 31 means "every value from 1 to 31 that is 7 apart from 1", and the range restarts at each month boundary. The same trap has sharper versions in the minute field, where the range is 0 to 59 and does not divide by every number you might pick. */45 is not every 45 minutes: it fires at :00 and :45, so the gaps alternate 45 minutes and 15. */61 is not every 61 minutes: 61 is past the end of the field, so the only value it produces is 0, and the schedule is hourly. 0-59/13 fires at :00, :13, :26, :39 and :52 and then waits eight minutes. cronstrue 3.24.0 — the library this page writes its own descriptions with — calls the first "Every 45 minutes" and the second "Every 61 minutes". The gap column in the table above is there so you never have to take anyone's sentence for it.
Which timezone does cron use, and how do I change it?
Plain cron uses the machine's local time, which means the schedule is a property of the host and not of the crontab — change /etc/localtime and every job moves. There is no timezone field in the five-field format, and the crontab(5) manual on macOS never mentions a zone at all. On Linux crons derived from cronie, the answer is CRON_TZ: cronie's crontab(5) describes it as "the time zone specific for the cron table", and it applies to the lines below it in the file, so it belongs at the top. Elsewhere the answer is a property of the scheduler rather than of cron: a Kubernetes CronJob has .spec.timeZone, and its API reference says that without it the schedule runs in "the time zone of the kube-controller-manager process" — usually UTC, but a fact about your control plane rather than a promise. A systemd timer's OnCalendar uses local time unless the expression says otherwise. The zone selector on this page is the whole reason it exists: pick the zone the scheduler will actually use, not the one your laptop is in, and the run times above change accordingly.
What happens to a cron job when the clocks change?
A cron schedule is a wall clock, so twice a year some wall clocks either do not exist or exist twice, and what a scheduler does about that is a choice it makes rather than arithmetic. The behaviour this page models is the one cronie's crontab(5) documents: "non-existent times, such as the missing hours during the daylight savings time conversion, will never match, causing jobs scheduled during the missing times not to be run. Similarly, times that occur more than once will cause matching jobs to be run twice." So in America/New_York a job at 30 2 * * * does not run at all on 8 March 2026, and a job at 0 * * * * runs twice on 1 November 2026, once at 01:00 EDT and once at 01:00 EST an hour later. The cron macOS ships can do better, but only if asked: man 8 cron documents a -s flag that enables "special handling of situations when the GMT offset of the local timezone changes", under which a job in a vanished interval runs "at the same absolute point of time as they would be in the old time zone" — and -o, which disables it "to be compatible with the old (default) behavior". Default, in that sentence, means off. macOS launches /usr/sbin/cron with no arguments at all, which is checkable in /System/Library/LaunchDaemons/com.vix.cron.plist. The manual's own BUGS section is blunter than any of this: "In general, it is not a good idea to schedule jobs during this period."
Why does @reboot have no next run?
Because it has no clock in it. @reboot is one of the eight special strings in man 5 crontab's table, and unlike the other seven it does not expand into five fields — @daily is "0 0 * * *" and @hourly is "0 * * * *", but @reboot means "run once, at startup". There is no time of day to compute and no next occurrence to show, so this page says that rather than reporting an error, which is what the library it is tested against does: cron-parser 5.10.0 throws "Validation error, cannot resolve alias" on it. Two practical consequences. The first is that @reboot fires when cron starts, not when the machine finishes booting — if your job needs the network or a mounted volume, cron makes no promise that either exists yet, and a systemd unit with the right After= is the honest tool. The second is that @reboot is the one @ string that does not travel: the Kubernetes CronJob docs list the other seven and cannot express this one, because there is no reboot for a controller to observe.
Why does my six-field expression mean something different here?
Because six fields is ambiguous and the two readings disagree by a factor of sixty. A crontab line has five fields, minute first. Quartz, Spring and node-cron take six, with seconds first — so 0 0 12 * * ? is noon in those and is not a crontab line at all. A sixth field can also be a year, which is Quartz's seventh field written without a seconds column, so 0 0 12 * * 2026 is midnight on the 12th of each month during 2026. This page decides between the two by looking at the field rather than guessing: 2026 cannot be a day of week, so that reading is year-last; a ? or a 0-7 value can only be the other one. It says which reading it took, and where both readings are valid it offers the flip. The related trap is the other direction. cron-parser 5.10.0 accepts three fields by reading them as seconds, minutes and hours, and accepts the empty string as * * * * *. Both mean that a typo which should have been refused becomes a job that runs every minute. This page counts the fields first and names the ones that are missing.