Cron by Platform

The same five fields, eight different sets of rules

Cron syntax is short and widely recognised, so almost every scheduler built since has adopted it, and almost every one has changed something along the way. Field counts and time zones vary between platforms. The special strings are accepted in some places and rejected in others, and delivery guarantees range from "within the minute" to "usually, eventually". This page lists, platform by platform, the differences that cause problems in practice.

Side-by-side comparison

Cron dialects at a glance
PlatformFieldsZone@daily etc.Minimum interval
Unix cron5System localYes1 minute
Kubernetes CronJob5UTC, or spec.timeZoneYes1 minute
GitHub Actions5UTC onlyNo5 minutes (and not guaranteed)
AWS EventBridge Rules6 (with year)UTC onlyNo1 minute
EventBridge Scheduler6 (with year)ConfigurableNo1 minute
Spring @Scheduled6 (with seconds)JVM default, or zoneYes1 second
Quartz6 or 7Trigger-specificNo1 second
Jenkins5 (+ H)System, or TZ= lineYes (@daily…)1 minute
Vercel Cron5UTC onlyNo1 minute (plan-dependent)
Cloudflare Workers5UTC onlyNo1 minute
systemd timersOwn syntaxSystem, or zone suffixOwn shorthandsSub-second

Read the field count and minimum interval columns together. Where the field count is 6 with a leading seconds field, a pasted Unix expression shifts by one place and silently means something else, which is the failure mode described in the Quartz guide. Where the minimum interval is above a minute, the platform can accept a valid expression and then not honour it.

Kubernetes CronJob

CronJobs use standard five-field syntax plus the @ shorthands. The details that matter most concern the time zone and what happens when a run starts late or runs long.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-report
spec:
  schedule: "0 3 * * *"
  timeZone: "Europe/Amsterdam"      # v1.27+; UTC without it
  concurrencyPolicy: Forbid          # Allow | Forbid | Replace
  startingDeadlineSeconds: 600
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: report
              image: registry.example.com/report:1.4.2

GitHub Actions

Five-field POSIX syntax, UTC only, and no @daily shorthands. The quoting matters: * at the start of a YAML scalar is invalid, so the expression must be quoted.

on:
  schedule:
    - cron: "0 3 * * 1-5"   # 03:00 UTC on weekdays
    - cron: "*/30 * * * *"  # multiple schedules are allowed
Scheduled workflows run late, and sometimes not at all

GitHub queues scheduled runs on shared infrastructure and deprioritises them during peak load. Delays of several minutes to over an hour are routine, and runs can be dropped. There is also a five-minute floor on the interval. GitHub also disables scheduled workflows automatically after 60 days without repository activity. If a job quietly stops after two months in a stable repo, this rule is the cause rather than the cron expression.

Because GitHub spreads scheduled runs according to load, avoid the top of the hour: 7 3 * * * is measurably more reliable than 0 3 * * *. Schedules run only on the default branch, and only from a workflow file that exists on that branch. Editing the schedule in a feature branch has no effect until the branch merges.

AWS EventBridge and Lambda

AWS uses a six-field dialect with a year field and Quartz-style ?, L and W, but without the seconds field. The fields are: minutes, hours, day-of-month, month, day-of-week, year.

cron(0 3 * * ? *)        # 03:00 UTC daily
cron(0 8 ? * MON-FRI *)  # 08:00 UTC weekdays
cron(0 12 L * ? *)       # noon on the last day of the month
rate(15 minutes)         # the simpler alternative

Where the schedule is a plain interval, rate(15 minutes) is clearer than a cron expression and avoids the whole dialect question.

Spring and Quartz

Spring's @Scheduled takes a six-field expression starting at seconds, with Unix day-of-week numbering (Sunday = 0) and support for L, # and the @daily-style macros. Quartz proper takes six or seven fields with Quartz numbering (Sunday = 1). They are not the same dialect, despite frequently appearing in the same application.

@Scheduled(cron = "0 0 3 * * *", zone = "Europe/Amsterdam")
public void nightly() { ... }

// every 30 seconds: not expressible in Unix cron
@Scheduled(cron = "0/30 * * * * *")
public void poll() { ... }

Always set zone explicitly. The JVM default in a container is UTC, so a schedule that worked on a developer machine in CET moves an hour or two once deployed. The Quartz guide covers the translation rules between all three dialects in detail.

Jenkins

Jenkins uses five-field syntax with one significant addition: H, the hash symbol, which spreads load by assigning each job a stable pseudo-random offset derived from its name.

H 3 * * *        # once between 03:00 and 03:59, same minute every day
H/15 * * * *     # every 15 minutes, at an offset unique to this job
H H(2-4) * * *   # once a day, somewhere between 02:00 and 04:59
H 9 * * 1-5      # weekday mornings, staggered

H is a useful addition and has no equivalent elsewhere. Without it, every job on the controller configured for 0 3 * * * starts at exactly the same second and the executor queue spikes. With it, the jobs are spread across the hour deterministically: a given job always gets the same minute, so its runs stay predictable.

Jenkins also accepts @daily, @weekly and friends, and a TZ=Europe/Amsterdam line above the schedule sets the zone per job.

Vercel, Netlify and Cloudflare

The edge and serverless platforms all use standard five-field syntax in UTC, but each has its own limits on granularity and delivery guarantees.

// vercel.json
{ "crons": [ { "path": "/api/cleanup", "schedule": "0 3 * * *" } ] }
# wrangler.toml: Cloudflare Workers
[triggers]
crons = ["0 3 * * *", "*/10 * * * *"]
# netlify.toml: scheduled functions
[functions."cleanup"]
  schedule = "0 3 * * *"

Across all three, treat the schedule as "at or after" rather than "at", and make the handler idempotent, because retried invocations are part of normal operation on these platforms.

systemd timers

systemd deliberately does not use cron syntax. Its OnCalendar format is more expressive and much easier to read:

[Timer]
OnCalendar=*-*-* 03:00:00
OnCalendar=Mon..Fri 09:00 Europe/Amsterdam
RandomizedDelaySec=300
Persistent=true

Two features have no cron equivalent. Persistent=true runs a missed job as soon as the machine comes back up, which matters on laptops and anything that is not always on. RandomizedDelaySec spreads load the way Jenkins' H does. Timers also inherit systemd's resource controls, journal logging and dependency ordering. A scheduled unit can, for example, require the network to be up, which cron cannot express.

systemd-analyze calendar "Mon..Fri 09:00" prints the next elapse, the same sanity check the Explainer provides for cron expressions.

Writing one expression for several platforms

If the same schedule has to work in more than one place, keeping to the common subset costs nothing and removes most of the risk:

Compose and verify the expression in the builder first; the run times it computes are the same for every platform that uses standard five-field syntax.