Skip to content
Control Plane Labs

Cron syntax, actually explained

The five standard cron fields, the Quartz six and seven field variants, macros, step values, the day-of-month versus day-of-week OR rule, and the timezone traps.

Ben Ennis

Published July 27, 2026

Cron syntax is small enough to learn in ten minutes and subtle enough that experienced engineers still get the day fields wrong. There are also at least three dialects in common use — POSIX/Vixie cron, Quartz, and the Kubernetes CronJob flavour — and they disagree about field counts, allowed characters, and what day-of-week numbering means. This is the reference for all three.

The five standard fields

┌──────────── minute       (0-59)
│ ┌────────── hour         (0-23)
│ │ ┌──────── day of month (1-31)
│ │ │ ┌────── month        (1-12 or JAN-DEC)
│ │ │ │ ┌──── day of week  (0-7 or SUN-SAT; 0 and 7 both mean Sunday)
│ │ │ │ │
* * * * *  command

The canonical description of the five fields and their ranges is the crontab(5) manual page, and the interface itself is specified in POSIX crontab. Read the manual page for your actual cron implementation when precision matters: Vixie cron, cronie, and busybox crond all differ around the edges.

Worked examples:

0 3 * * *        03:00 every day
*/15 * * * *     every 15 minutes, on :00 :15 :30 :45
0 9-17 * * 1-5   hourly from 09:00 to 17:00, Monday to Friday
30 2 1 * *       02:30 on the first of every month
0 0 * * 0        midnight on Sunday
15 14 1 * *      14:15 on the first of every month

The operators

Four operators, and they compose within a single field:

  • * — every value in the field.
  • , — a list: 0,15,30,45.
  • - — an inclusive range: 9-17.
  • / — a step, applied to a range: 0-23/2 is every second hour; */15 is shorthand for 0-59/15 in the minute field.

Steps are the operator people misread. */7 in the minute field does not mean “every seven minutes” in the way you might hope — it means minutes 0, 7, 14, 21, 28, 35, 42, 49, and 56, and then the hour rolls over and the next fire is at minute 0, four minutes later. Any step that does not divide the field range evenly produces an uneven gap at the boundary. Use divisors of 60 for minutes and of 24 for hours if you want a regular interval.

Names work in the month and day-of-week fields — JAN-DEC and SUN-SAT — but they are case-insensitive three-letter abbreviations only, and you cannot use a step with a name range in every implementation. Numbers are more portable.

The day-of-month and day-of-week rule

This is the one genuine trap in standard cron. Normally, a job runs when all fields match. But if both the day-of-month and day-of-week fields are restricted — meaning neither is * — the job runs when either matches. The crontab(5) manual page states it directly and gives this example:

30 4 1,15 * 5    04:30 on the 1st and 15th of each month, PLUS every Friday

That is almost certainly not what someone writing it intended. If you want “the first Monday of the month”, standard cron cannot express it; the usual workaround is to schedule every Monday and exit early in the script when the day of month exceeds 7:

# 0 6 * * 1  — runs every Monday, does work only on the first one
[ "$(date +%-d)" -le 7 ] || exit 0

Quartz can express it directly, which is one of the few good reasons to prefer Quartz syntax.

Macros

Most implementations accept @-prefixed shorthands. The crontab(5) page lists @reboot, @yearly (and its synonym @annually), @monthly, @weekly, @daily, and @hourly.

@yearly    0 0 1 1 *
@monthly   0 0 1 * *
@weekly    0 0 * * 0
@daily     0 0 * * *
@hourly    0 * * * *
@reboot    once, when the cron daemon starts

@reboot is the odd one out: it is not a schedule, it is an event, and it does not exist in Kubernetes CronJobs or Quartz. Kubernetes additionally accepts @midnight as a synonym for @daily, per the CronJob documentation.

Be wary of @daily and @hourly in a fleet: every host runs them at exactly :00, which is how you get a thundering herd against a shared database at midnight. Spread the load with an explicit random-looking minute instead.

Quartz: six or seven fields

Quartz is the Java scheduler dialect, and it is what you will meet in Spring applications, Jenkins-adjacent tooling, and several commercial schedulers. It uses six mandatory fields plus an optional seventh:

seconds       0-59
minutes       0-59
hours         0-23
day of month  1-31
month         1-12 or JAN-DEC
day of week   1-7 or SUN-SAT   (1 = Sunday, unlike standard cron)
year          1970-2099        (optional)

Two differences will break a copied expression immediately. Quartz has a seconds field first, so a standard five-field expression pasted into Quartz is interpreted one field to the left. And Quartz numbers day-of-week from 1, where standard cron numbers from 0 — so 5 means Friday in cron and Thursday in Quartz.

Quartz also adds four special characters that standard cron does not have:

  • ? — “no specific value”, used in one day field when the other is set. Quartz requires exactly one of the two day fields to be ?.
  • L — last. L in day-of-month is the last day of the month; 6L in day-of-week is the last Friday.
  • W — nearest weekday. 15W fires on the weekday closest to the 15th.
  • # — the nth weekday. 6#3 is the third Friday of the month.
0 0 3 ? * MON-FRI     03:00:00 on weekdays
0 0 12 L * ?          noon on the last day of every month
0 15 10 ? * 6#3       10:15 on the third Friday

The formal grammar is in the CronExpression API documentation. Note that Kubernetes accepts ? as a synonym for * but supports none of L, W, or #, so a Quartz expression will often parse in a CronJob and mean something different.

Timezones, which is where the real incidents come from

Standard cron runs in the local timezone of the machine, which means a job scheduled for 02:30 either runs twice or not at all on daylight-saving transition days. The only reliable fix is to run schedulers in UTC and convert at the edges.

Kubernetes made this explicit: .spec.timeZone on a CronJob accepts an IANA timezone name and has been stable since v1.27, per the CronJob documentation. Setting CRON_TZ or TZ inside the schedule string is not supported and will be rejected. Without the field, the schedule is interpreted in the timezone of the kube-controller-manager, which is a property of your control plane rather than of your manifest — a bad thing to depend on.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "0 3 * * *"
  timeZone: "Etc/UTC"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 300
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: report
              image: reports:1.4.2

Kubernetes CronJob behaviour worth knowing

Three documented behaviours account for most CronJob surprises, all described on the CronJob concept page and in the CronJob v1 API reference:

The controller polls every ten seconds. A startingDeadlineSeconds under 10 may cause the job never to be scheduled at all.

More than 100 missed schedules stops scheduling. If the controller cannot reach a scheduled time — control plane restart, clock skew, a long outage — it counts how many schedules it missed since the last run. Past 100, it gives up and logs too many missed start times. Setting startingDeadlineSeconds bounds the window it examines, which is the fix.

Execution is at-least-once, not exactly-once. The documentation states plainly that two Jobs may be created for one scheduled time, or none. Idempotent job bodies are not optional.

concurrencyPolicy is Allow by default. For anything that touches shared state, set it to Forbid (skip the new run) or Replace (kill the old one) and decide which failure you prefer.

When cron is the wrong tool

Cron is a time trigger with no state, no retries, no dependency graph, and no history. If you need any of those, use something else. On a single Linux host, systemd timers give you Persistent=true for catch-up after downtime, RandomizedDelaySec for herd avoidance, and journal-integrated logs, using the calendar syntax documented in systemd.time(7). In Java, Spring’s scheduling support is documented in the Spring Framework reference. On AWS, EventBridge uses its own six-field variant described in the EventBridge cron documentation — another dialect, with its own day-of-week numbering.

And if the job’s real trigger is an event rather than a clock, cron is a polling workaround. Schedule on the event.

To check an expression before you ship it, the cron expression builder on this site parses both standard and Quartz syntax, describes it in English, and shows the next ten fire times in the timezone you choose.

Frequently asked questions

Why does my job with both day fields set run more often than expected?+
Because cron ORs them. When neither day-of-month nor day-of-week is *, the job runs if either matches. 30 4 1,15 * 5 runs on the 1st, the 15th, and every Friday — see crontab(5).
Does */7 in the minute field mean every seven minutes?+
Not exactly. It fires at minutes 0, 7, 14, 21, 28, 35, 42, 49, and 56, so the gap across the hour boundary is four minutes. Use a divisor of 60 if you need an even interval.
How do I set a timezone for a Kubernetes CronJob?+
Use the .spec.timeZone field with an IANA name such as Europe/London. It has been stable since Kubernetes v1.27. Setting CRON_TZ inside the schedule string is not supported.
Can I paste a Quartz expression into a Kubernetes CronJob?+
No. Quartz has a leading seconds field and numbers day-of-week from 1 rather than 0, so the expression will either be rejected or silently mean something else. Kubernetes accepts ? as a synonym for * but not L, W, or #.
Why did my CronJob stop scheduling entirely?+
Most likely it missed more than 100 schedules — after a control-plane outage or clock skew — and the controller logged too many missed start times. Set startingDeadlineSeconds so the controller only considers a bounded window.

Tags: #cron, #kubernetes, #scheduling, #operations