Development

Cron expressions explained (and the OR trap)

Five numbers and a few symbols: cron looks like the simplest thing in systems administration. And it is, until the expression that was supposed to run "every Friday the 13th" starts firing on every 13th and every Friday. That is not the only trap, but it is the one most people fall into, because it contradicts the intuitive left-to-right reading. This guide opens the five fields, defines each operator with an example, shows why */5 does not mean "every 5 minutes from now" and separates POSIX cron from the Quartz extensions (seconds, ?, L, W, #) you meet when copying expressions off the internet. Build and test yours in the [Cron Generator](tool:cron-generator) as you read, it lists the next fire times, which is exactly where the OR trap shows its face.

J-Kit14 min readIntermediate
  • Cron
  • Scheduling
  • Linux
  • Development

Key takeaways

  • Five fields: minute (0–59), hour (0–23), day-of-month (1–31), month (1–12) and day-of-week (0–7, with 0 and 7 = Sunday in Vixie cron).
  • When both day-of-month AND day-of-week are restricted, cron fires when EITHER matches (logical OR), not when both match.
  • */5 is the minutes divisible by 5 (0,5,10…), not "5 minutes after install"; the step always counts from the start of the range.
  • Seconds, ?, L, W and # are Quartz/Spring extensions, not POSIX, do not paste those expressions into a Linux crontab.

The five fields

A crontab is a table of schedules read by the cron daemon (in practice, crond). Each line has five time fields followed by the command to run. The fields are, in order: minute, hour, day-of-month, month and day-of-week. The daemon wakes once a minute, compares the clock against every line and runs the command when the fields match. The base specification is POSIX (The Open Group), but almost every Linux runs a Vixie cron variant (maintained today as cronie), which adds handy extensions, the @daily shortcuts, month names and the value 7 for Sunday.

 ┌───────────── minute        (0-59)
 │ ┌─────────── hour          (0-23)
 │ │ ┌───────── day of month  (1-31)
 │ │ │ ┌─────── month         (1-12)
 │ │ │ │ ┌───── day of week   (0-7, 0/7 = Sun)
 │ │ │ │ │
 * * * * *   command to run
The classic five-field diagram. An asterisk means "any value" in that field.
The five fields, their ranges and the special names Vixie cron accepts.
FieldRangeSpecial values/names
Minute0–59
Hour0–23
Day of month1–31
Month1–12names JAN–DEC
Day of week0–70 and 7 = Sunday; names SUN–SAT

Day-of-week is the quiet gotcha. Under strict POSIX, the range is 0–6 with 0 = Sunday. Vixie cron extends it to 0–7 and makes **both 0 and 7 mean Sunday**, a courtesy for people who expect the week to end on 7. This matters when you write 1-5 (Monday to Friday) or 6,0 (the weekend). Month and day names (JAN, SUN) are also a Vixie extension, not POSIX; on a minimal system, prefer the numbers. The Cron Generator treats 7 as Sunday and validates the ranges field by field, so it is a good place to confirm that sun and 0 produce the same schedule.

The operators and the step

Each field takes four constructs. The asterisk matches every value; the comma lists individual values; the hyphen defines an inclusive range; and the slash applies a step, skipping by N within a range (or within an asterisk, which stands for the field's full range). POSIX defines only *, , and -; the / step is a Vixie extension, one more reason not to assume every expression runs everywhere.

The four operators, with one example and result each.
OperatorMeaningExampleResult
*all values of the field* * * * *every minute
,list of values0 0,12 * * *at 00:00 and 12:00
-inclusive range0 9-17 * * *hourly from 09 to 17
/step within a range*/15 * * * *at minutes 0, 15, 30, 45

**Worked example.** Take */15 9-17 * * 1-5, every 15 minutes, from 9am to 5pm, Monday to Friday. Here only day-of-week is restricted (day-of-month is *), so there is no trap: it is a clean AND between the time fields and the weekdays. Starting from a Thursday, 2026-07-09, at 16:07, the next fires are: 16:15, 16:30, 16:45, 17:00, 17:15, 17:30 and 17:45 (hour 17 is inside the 9-17 range). After 17:45 the window closes; the next is Friday 2026-07-10 at 09:00. The weekend is skipped, so after Friday the job only returns on Monday 2026-07-13 at 09:00.

The OR trap: day-of-month and day-of-week

Here is the rule that breaks everyone's intuition. You read the expression left to right and assume every field has to match at once, one big AND. That holds for minute, hour and month. But day-of-month and day-of-week get special treatment: when **both** are restricted (neither is *), cron fires when **either one** matches. It is an OR, not an AND. Vixie's crontab(5) states, verbatim: "If both fields are restricted (i.e., do not contain the '*' character), the command will be run when either field matches the current time." POSIX describes the same semantics in other words.

There is no way to express "Friday the 13th" in a single POSIX cron field, precisely because of the OR. The idiomatic fix is to schedule for every 13th, 0 0 13 * *, and test the weekday inside the command itself, e.g. [ "$(date +\%u)" = "5" ] && /path/script. Since only day-of-month is restricted (day-of-week is back to *), the OR rule does not kick in: cron fires on every 13th and test decides whether that 13th is a Friday. The practical rule is simple: if you need both day fields to act together as an AND, leave one of them as * and move the other condition into an if in the script.

Paste 0 0 13 * 5 and read the next-fires list: Fridays and 13ths mixed together. Then compare with 0 0 13 * *.Open the tool full page

Shortcuts: @daily, @reboot and friends

Instead of the five fields, Vixie cron accepts nicknames starting with @. They are not part of POSIX, but they exist on practically every Linux and make the intent obvious. Each nickname maps to a five-field expression, except @reboot, which has no equivalent because it means "once, when cron starts".

Vixie cron's standard nicknames and the expression each one stands for.
NicknameEquivalent toWhen it runs
@yearly / @annually0 0 1 1 *January 1st, 00:00
@monthly0 0 1 * *1st of each month, 00:00
@weekly0 0 * * 0every Sunday, 00:00
@daily / @midnight0 0 * * *every day, 00:00
@hourly0 * * * *every hour, at minute 0
@reboot(no equivalent)once, when cron starts

Two details: @midnight is a synonym for @daily in cronie's code, even though the current manual only documents @daily; and @reboot runs when the **daemon** starts (typically at boot), not at every login, if the service restarts on its own, the job fires again. Two questions that keep coming back, how cron handles daylight saving time and what exactly counts as a "reboot", deserve a little more room.

Cron and daylight saving time (DST): skipped and repeated jobs

Cron runs in the time zone of the system where the daemon lives, and DST transitions shift the local clock. The cron(8) manual describes special handling for jumps of less than 3 hours (the DST case). In spring, when the clock moves forward and an hour "disappears", jobs that would have run in the skipped hour are run soon after the change. In autumn, when the clock moves back and an hour repeats, jobs that fall in the repeated hour are not run again. This applies to specific-time schedules; wildcard jobs (e.g. * * * * *) run normally over the new time.

Changes of more than 3 hours are treated as clock corrections, and the new time takes effect immediately. The operational lesson: for time-zone-sensitive tasks, run the daemon in UTC or avoid the transition window (somewhere between 1am and 3am, depending on the country). If you need to reason about these jumps, the time zones, UTC and daylight saving guide details why "02:30" may not exist or may exist twice, and the time zone converter helps align the server's time with your audience's.

What exactly @reboot runs

@reboot fires exactly once, when the cron service starts. On most systems that coincides with the machine booting, but it is not guaranteed: if you restart only the daemon (for example, systemctl restart cron), the @reboot jobs run again, without the machine having rebooted. There is no time attached either, it does not "wait" for a moment; it runs as soon as cron starts and reads the crontab.

That is why @reboot is fragile as a service-startup mechanism. If the goal is to bring a process up with the system, a systemd service (with dependencies, automatic restart and boot ordering) is more robust. Reserve @reboot for idempotent, cheap tasks, clearing a temp directory, rebuilding a cache, where running again after a daemon restart does no harm.

Not every "cron" is POSIX: Quartz and Spring

A lot of the expressions floating around the internet do not run in a Linux crontab, they are Quartz, the Java-world scheduler, or the format Spring uses. The most visible clue is the field count: Quartz uses six (seconds, minute, hour, day-of-month, month, day-of-week) plus an optional seventh for the year; Spring uses six, with seconds up front. If you count six numbers where you expected five, you are probably looking at an expression with a seconds field, pasting it into a system crontab makes cron complain or misread everything.

POSIX / Vixie cron (Linux)

  • 5 fields: minute, hour, day-of-month, month, day-of-week.
  • Day-of-week 0–7 (0 and 7 = Sunday).
  • Operators *, ,, - and (in Vixie) /.
  • Both day-of-month and day-of-week restricted = OR.
  • No seconds, no ?, L, W, #.

Quartz / Spring (Java)

  • 6 fields (seconds up front) + optional year in Quartz.
  • Day-of-week 1–7 (1 = Sunday, 7 = Saturday).
  • ? = "no specific value" in one of the day fields.
  • L = last day; W = nearest weekday.
  • # = nth weekday of month (6#3 = 3rd Friday).

Quartz solves the OR trap differently: it **forbids** specifying day-of-month and day-of-week at the same time. You must put ? in one of the two, making explicit which day rules. The documentation states you "must currently use the '?' character in one of these fields". That is why Quartz expressions often carry a ? where Linux cron would have a *, and why 6#3 (third Friday of the month), which Quartz packs into a single field, needs a script-side test under POSIX cron. When migrating between the two worlds, rewrite the expression from intent, do not copy the symbols: beyond the seconds field and the ?, the day-of-week numbering shifts (0 = Sunday on Linux, 1 = Sunday in Quartz).

Ready-made recipes

Most everyday schedules fit into half a dozen patterns. The table below lists verified five-field expressions, with what each one does. All of them dodge the OR trap: whenever the two day fields could collide, one of them stays as *.

Verified cron recipes. Test any of them in the generator to see the next fires.
ExpressionWhat it does
0 0 * * *Every day at midnight (same as @daily).
*/15 * * * *Every 15 minutes (at minutes 0, 15, 30, 45).
0 9 * * 1-5At 09:00, Monday to Friday.
0 */6 * * *Every 6 hours (00:00, 06:00, 12:00, 18:00).
0 0 1 * *1st of each month, 00:00 (same as @monthly).
0 0 * * 0Every Sunday at midnight (same as @weekly).
23 0-23/2 * * *At minute 23 of every even hour (00:23, 02:23, …, 22:23).
30 3 1 1 *At 03:30 on January 1st, every year.

One last hygiene note: always schedule with a fallback for timing. If the server is off at the exact moment, plain cron simply misses the fire (anacron covers part of this on desktops). And instrument the job, a cron that runs but fails silently is worse than one that never runs. A cheap pattern is for the job to hit a "heartbeat" endpoint at the end; if the heartbeat does not arrive, you get alerted. To read what the server returns on that heartbeat, the essential HTTP status codes guide separates the 2xx that confirms success from the 5xx that signals failure. And when the job writes Unix timestamps to its logs, the timestamp converter turns those numbers into readable dates while you debug.

Frequently asked questions

Why does my "Friday the 13th" cron run on other days?
Because 0 0 13 * 5 has both day fields restricted, and cron combines them with OR: it fires on every 13th OR every Friday. To run only on Friday the 13th, schedule 0 0 13 * * and test the weekday inside the script, for example with [ "$(date +\%u)" = "5" ].
Does */5 mean "every 5 minutes from now"?
No. The step counts from the start of the field's range, so */5 matches minutes 0, 5, 10, …, 55, those divisible by 5, regardless of when you installed the job. Steps that do not divide the range create uneven spacing: */45 in minutes matches only 0 and 45.
0 or 7 for Sunday, which one?
In Vixie cron (most Linux), both 0 and 7 mean Sunday, so either works. Under strict POSIX the range is 0–6, with only 0 counting as Sunday. For maximum portability, prefer 0; and remember Quartz is different: there Sunday is 1.
Why does a 6-field expression not work in my crontab?
It is almost certainly a Quartz or Spring expression, which put a seconds field up front (6 fields, plus a year field in Quartz). Linux cron expects exactly 5 fields and understands neither seconds nor the ?, L, W and # characters. Rewrite from intent, without the seconds field.
Does cron respect daylight saving time?
Cron runs in the system time zone. For jumps of less than 3 hours (the DST case), cron(8) has special handling: jobs in the skipped hour (spring) run soon after the change, and jobs in the repeated hour (autumn) do not run again. To avoid surprises, run the daemon in UTC or steer clear of the transition window.

Cron is five fields and four operators, but two details trip up anyone trusting intuition: */n counts from the field's zero, not from "now", and day-of-month with day-of-week become an OR when both are restricted. Master those two points, learn to tell POSIX cron from the Quartz extensions, and always validate by the next fires before trusting a schedule.

Sources & references

  1. The Open Group, crontab (POSIX Base Specifications)
  2. crontab(5), Vixie/cronie man page
  3. cron(8), man page (daylight saving / DST)
  4. cronie, crontab(5) and entry.c source
  5. Quartz Scheduler, CronTrigger tutorial (?, L, W, #)