Cron by Platform

The same five fields, eight different sets of rules

Cron syntax won because it is short and everyone recognises it, so almost every scheduler built since has adopted it — and almost every one has changed something. Field counts differ, time zones differ, the special strings are supported in some places and rejected in others, and the delivery guarantees range from "within the minute" to "usually, eventually". This page is a per-platform reference for the differences that actually bite.

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

Two columns are worth reading together: field count and minimum interval. Anywhere the field count is 6 with a leading seconds field, a pasted Unix expression shifts by one place and silently means something else — the failure mode described in the Quartz guide. Anywhere the minimum interval is above a minute, a valid expression can be accepted and then not honoured.

Kubernetes CronJob

Standard five-field syntax, plus the @ shorthands. The important details are around time zone and what happens when a run is late or 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. Scheduled workflows are additionally disabled automatically after 60 days without repository activity — a job that quietly stops after two months of a stable repo is this, not your cron expression.

Because of the load-spreading, 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 changes nothing until it 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 genuinely useful 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 — the same job always gets the same minute, so 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, and all differ in granularity and 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: a retried invocation is a normal event, not an incident.

systemd timers

systemd does not use cron syntax at all, which is the point — 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, logging into the journal, and dependency ordering — a scheduled unit can 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.