Automation guide

How to Read, Write and Test a Cron Expression

Field order in Unix and Quartz cron, what step and range syntax really mean, the day-of-month plus day-of-week OR rule, daylight-saving behaviour, and how to preview a schedule before you deploy it.

A cron expression is five to seven whitespace-separated fields, each one narrowing a component of time. The syntax takes ten minutes to learn and years to stop misreading, because two widely used dialects disagree about field order, one operator does not mean what it looks like, and the two day fields combine with OR instead of AND. This guide covers all three, plus the daylight-saving behaviour that silently skips or repeats a run.

First establish which dialect you are in

Cron evaluates a schedule by checking, at each tick, whether the current time satisfies every field. What differs between implementations is how many fields there are and what each position means. Classic Unix cron — the Vixie cron that backs crontab on most Linux distributions, plus BusyBox, cronie and almost every POSIX system — uses five fields: minute, hour, day of month, month, day of week. The smallest unit it can express is one minute; there is no seconds field, and no amount of syntax will give you one.

Quartz, the Java scheduler whose syntax was adopted by Spring, Elastic Job, XXL-Job and a long tail of JVM tooling, uses six or seven fields. It prepends a seconds field and optionally appends a year. That single prepended field is the cause of most copy-paste failures: an expression that means "every day at midnight" in one dialect means "every minute of midnight" in the other, and neither will complain.

A six-field expression is genuinely ambiguous on sight. It may be Quartz, or it may be one of the seconds-first dialects used by cloud schedulers and Go libraries that keep the Unix semantics and simply add seconds in front. The tell is the Quartz-only characters: if a day field contains ?, L, W or #, you are looking at Quartz. If it does not, you have to know the runtime. The Cron tool on this site labels a six-field expression without those characters as seconds-first but ambiguous, which is the honest answer rather than a guess.

Before writing anything, confirm the dialect from the scheduler's own documentation, not from a Stack Overflow answer about a different scheduler. The two minutes this costs is cheaper than a nightly job that has been running every minute for a week.

Position numbers are counted from the left of the expression. Note that the two dialects number the days of the week differently — this is a real source of off-by-one schedules.

FieldUnix positionQuartz positionRangeExtra characters
Secondnot present10-59* , - /
Minute120-59* , - /
Hour230-23* , - /
Day of month341-31Quartz adds ? L W
Month451-12 or JAN-DEC* , - /
Day of week56Unix 0-7 (0 and 7 = Sunday); Quartz 1-7 (1 = Sunday) or SUN-SATQuartz adds ? L #
Yearnot present7 (optional)1970-2099* , - /

Four operators, and the one everybody misreads

Every field accepts the same four constructs. An asterisk matches every value in the field's range. A comma builds a list: 0,15,45. A hyphen builds an inclusive range: 9-17. A slash applies a step, and this is the one that gets misread.

A step is not an interval. It is a stride taken across the range it is attached to, and that range restarts at the boundary of the next larger unit. So */20 in the minute field means minutes 0, 20 and 40 of every hour — which happens to be every twenty minutes, because 20 divides 60 evenly. Write */45 and you get minutes 0 and 45 only: the job fires at 00:00, 00:45, 01:00, 01:45, with a fifteen-minute gap the author did not intend. If you need a genuine forty-five-minute interval, cron cannot express it; use a timer that schedules its own next run, or a systemd timer with OnUnitActiveSec.

A step can also be attached to an explicit range rather than to an asterisk. 5-55/10 in the minute field matches 5, 15, 25, 35, 45 and 55. In Quartz the equivalent idiom is start/step, so 5/10 means "from 5, every 10" up to the end of the field. Unix cron accepts the same form in most implementations but it is less portable than the explicit range.

Lists and ranges combine freely inside one field. 0 9-17 * * 1-5 is the top of every hour between nine and five, Monday to Friday, and reads cleanly. Once an expression needs three commas and a nested step, it has stopped being self-documenting, and the comment above it in the crontab is doing the real work.

Step syntax, read carefully
*/20 * * * *      minutes 0, 20, 40        -> a real 20-minute interval
*/45 * * * *      minutes 0 and 45         -> 45 min, then 15 min. Almost never intended.
5-55/10 * * * *   minutes 5,15,25,35,45,55
0 */2 * * *       hours 0,2,4,...,22       -> every two hours on the hour
0 9-17 * * 1-5    09:00 through 17:00, Monday to Friday
*/15 9-17 * * *   09:00, 09:15, 09:30, 09:45, 10:00, ... 17:45

Quartz, six fields, seconds first:
0 0 12 ? * MON-FRI    12:00:00 on weekdays
0 */5 * ? * *         second 0 of minutes 0,5,10,... every day

The trap: day-of-month and day-of-week are OR, not AND

Cron combines its fields with AND — the minute must match and the hour must match and the month must match. There is one documented exception, inherited from the original implementation and codified in POSIX, and it catches almost everyone eventually. When both the day-of-month field and the day-of-week field are restricted, meaning neither is an asterisk, the job runs on a day that matches either one.

The canonical victim is 0 0 13 * 5, written by someone who wanted midnight on Friday the 13th. What it actually schedules is midnight on the 13th of every month, and midnight on every Friday. Starting from 21 September 2026 in UTC, the preview reads: Friday 25 September, Friday 2 October, Friday 9 October, then Tuesday 13 October, then Friday 16 October. The Tuesday is the giveaway. A job intended to run twelve times a year would run about sixty-four.

When one of the two day fields is an asterisk, the exception does not apply and nothing surprising happens. 0 0 * * 5 is every Friday. 0 0 13 * * is the 13th of every month. The OR behaviour only appears when both fields are constrained, which is exactly the case people write when they are trying to express an intersection.

Quartz removes the ambiguity by refusing to let you create it: exactly one of the two day fields must be ?, meaning "no specific value". If you write * in both, Quartz rejects the expression rather than picking a behaviour. This is why Quartz expressions in the wild almost always contain a question mark, and why stripping the question mark to convert to Unix cron changes the meaning.

If you genuinely need an intersection, cron is the wrong layer. Schedule the superset and guard inside the job. In a crontab, remember that an unescaped percent sign is turned into a newline before the command runs, so any date format specifier has to be escaped with a backslash.

The OR rule, and the guard that works around it
# Intended: midnight on Friday the 13th.
0 0 13 * 5

# Actual next runs from 2026-09-21, UTC:
#   Fri 2026-09-25 00:00
#   Fri 2026-10-02 00:00
#   Fri 2026-10-09 00:00
#   Tue 2026-10-13 00:00   <- day-of-month matched on its own
#   Fri 2026-10-16 00:00

# Correct: schedule the 13th, then check the weekday in the job.
# In a crontab the % must be escaped, or cron turns it into a newline.
0 0 13 * * [ "$(date +\%u)" = 5 ] && /usr/local/bin/report.sh

The Quartz-only characters

Beyond the four shared operators, Quartz defines four characters that Unix cron does not understand at all. They exist because the schedules businesses actually ask for — last working day of the month, second Tuesday, nearest weekday to the 15th — cannot be written with ranges and steps.

These characters are the main reason a Quartz expression cannot simply be truncated into a five-field one. The Cron tool on this site normalises ? to * so the expression can be previewed, and handles W by computing the scheduled date and then shifting it to the nearest weekday, which matches Quartz's behaviour including its refusal to cross a month boundary. Treat the preview as a close model of Quartz rather than as Quartz itself, and confirm anything unusual against the scheduler you will actually deploy to.

One detail worth repeating: in Quartz the day-of-week numbers start at 1 for Sunday, so Friday is 6 and 6L is the last Friday of the month. In Unix cron Sunday is 0 (and 7 is also accepted), so Friday is 5. Converting an expression between dialects means renumbering the weekday field, not just adding or removing a seconds field.

CharacterValid inMeaningExample
?day-of-month, day-of-weekNo specific value. Required in exactly one of the two day fields.0 0 12 ? * MON
Lday-of-monthLast day of the month. L-3 is three days before the last day.0 0 23 L * ?
Lday-of-weekThe last given weekday of the month.0 0 9 ? * 6L (last Friday)
Wday-of-monthNearest weekday to that date, never crossing into another month.0 0 8 15W * ?
#day-of-weekThe nth given weekday of the month.0 0 9 ? * 3#2 (second Tuesday)
LWday-of-monthLast weekday of the month, the usual payroll schedule.0 0 17 LW * ?

Time zones and daylight saving

A cron expression contains no time zone. The daemon interprets it in the system's local zone unless the crontab sets CRON_TZ (Vixie cron) or TZ, and container images almost always run as UTC regardless of where the cluster is. A schedule that was correct on a developer's laptop and wrong in production usually differs by nothing but the zone.

Twice a year, a local-time schedule meets a discontinuity. On the spring-forward transition a wall-clock hour does not exist. In New York on 8 March 2026 the clock jumps from 01:59:59 EST to 03:00:00 EDT, so 0 2 * * * has no matching instant. Implementations disagree about what to do: Vixie cron runs jobs that would have fallen in the skipped hour once, immediately after the jump; some schedulers skip the day entirely; the preview on this site reports 03:00 EDT for that date. On the autumn transition the opposite happens — in New York on 1 November 2026 the local hour from 01:00 to 01:59 occurs twice, so 30 1 * * * has two valid instants. Vixie cron runs it once; a naive scheduler that simply re-derives the next local time can run it twice.

The practical rule is that jobs which only need to happen regularly should run in UTC, where neither problem exists. Jobs that are genuinely tied to local human time — an invoicing cut-off, a nine o'clock notification, a report someone reads before a morning meeting — need a named zone, and should avoid the hours between midnight and 03:00 local, which is where both transitions live. Moving a nightly job from 02:00 to 04:00 removes an entire class of incident.

Named zones also protect you from political changes. A fixed offset such as +02:00 is a number; Europe/Berlin is a rule that the tz database updates when a government changes the rules. Schedulers that accept a zone name should always be given one.

0 2 * * * across both transitions, America/New_York
Spring forward, 2026-03-08 (02:00 local does not exist):
  Sat 2026-03-07  02:00:00 EST
  Sun 2026-03-08  03:00:00 EDT   <- shifted, not skipped, by this parser
  Mon 2026-03-09  02:00:00 EDT

Autumn back, 2026-11-01 (01:00-01:59 local occurs twice) with 30 1 * * *:
  Sat 2026-10-31  01:30:00 EDT
  Sun 2026-11-01  01:30:00 EDT   <- the first of the two 01:30s
  Mon 2026-11-02  01:30:00 EST

Preview before you deploy

An expression that parses is not an expression that is correct. Parsing proves the syntax; only a list of concrete future timestamps proves the intent. Paste the expression into the Cron tool, set the time zone the job will actually run in, and read the next several runs out loud. If the list contains a date you cannot explain, you have found a bug before it found you.

Read the gaps rather than the individual entries. Most real cron defects show up as a spacing anomaly: a stride that resets at an hour boundary, a day that appears for no reason, a monthly job that skips February, a weekly job that lands on the wrong side of a weekend. Eight consecutive runs are usually enough to expose all four.

Finally, treat the expression as code that needs a comment. Write the intent in English on the line above, in the crontab or in the scheduler's description field, and make sure the two agree. The next person to read 0 0 13 * 5 will assume it means what its author meant, and the only defence against that is a sentence saying what the schedule is for.

  • Count the fields first, then confirm the dialect from the scheduler's own documentation.
  • Check the two day fields: if neither is * (or ? in Quartz), the schedule is an OR, not an AND.
  • Recheck any step that does not divide its range evenly — */45, */7, */25 are almost always mistakes.
  • Set the preview to the production time zone, not your own, and look at a run near a daylight-saving transition.
  • Move nightly jobs out of the 00:00-03:00 local window unless the exact hour matters.
  • Convert a previewed run to an epoch value with the Timestamp tool when you need to match it against a log line.

What to remember

  • Count the fields before reading them: five means Unix cron, seven means Quartz, and six is ambiguous unless a ?, L, W or # settles it.
  • When both the day-of-month and day-of-week fields are restricted, standard cron runs when either matches, so 0 0 13 * 5 fires on every 13th and every Friday.
  • A slash is a stride over a range, not an interval, so any step that does not divide its field evenly produces an uneven schedule.
  • Cron expressions carry no time zone; run jobs in UTC where you can, and keep local-time jobs away from the 00:00-03:00 window where daylight-saving transitions live.
  • Always read a list of concrete next run times in the production time zone before deploying a schedule, because parsing only proves syntax.

Continue with related checks and tools