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
| Platform | Fields | Zone | @daily etc. | Minimum interval |
|---|---|---|---|---|
| Unix cron | 5 | System local | Yes | 1 minute |
| Kubernetes CronJob | 5 | UTC, or spec.timeZone | Yes | 1 minute |
| GitHub Actions | 5 | UTC only | No | 5 minutes (and not guaranteed) |
| AWS EventBridge Rules | 6 (with year) | UTC only | No | 1 minute |
| EventBridge Scheduler | 6 (with year) | Configurable | No | 1 minute |
Spring @Scheduled | 6 (with seconds) | JVM default, or zone | Yes | 1 second |
| Quartz | 6 or 7 | Trigger-specific | No | 1 second |
| Jenkins | 5 (+ H) | System, or TZ= line | Yes (@daily…) | 1 minute |
| Vercel Cron | 5 | UTC only | No | 1 minute (plan-dependent) |
| Cloudflare Workers | 5 | UTC only | No | 1 minute |
| systemd timers | Own syntax | System, or zone suffix | Own shorthands | Sub-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
- Time zone. Before v1.27 the schedule was evaluated in the controller manager's zone, which is UTC on most clusters and occasionally not.
spec.timeZonetakes an IANA name and is the right way to pin it. concurrencyPolicy. Defaults toAllow, which starts a new Job even if the last one is still running.Forbidskips the new run;Replacekills the old one. This is Kubernetes' equivalent offlock, and it defaults to the unsafe setting.startingDeadlineSeconds. If the controller was unable to start a Job within this window, that run is counted as missed. Miss 100 schedules in a row and the controller stops scheduling the CronJob entirely and logs an error — a failure mode you will only see if you are watching controller logs.- Not exactly-once. The documentation is explicit that a CronJob may create two Jobs, or none, for a single schedule. Idempotency is required, not optional.
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
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
- The year field is required for Rules. Six fields, not five — a pasted Unix expression is rejected.
?is required in whichever of day-of-month and day-of-week you are not using, exactly as in Quartz. You cannot restrict both.- Day-of-week is 1–7 with Sunday as 1, the Quartz numbering, not the Unix one.
- UTC only for Rules. EventBridge Scheduler — the newer, separate service — does support a time zone and flexible time windows, and is the better choice for anything human-facing.
- Delivery is at-least-once and may be delayed. Lambda targets can be invoked more than once for a single schedule.
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 * * *"
- Vercel evaluates in UTC, with the number of cron jobs and the minimum interval both depending on the plan. Invocations are best-effort and may be delayed by a minute or two.
- Cloudflare Workers use UTC, support multiple triggers per Worker, and deliver the schedule to a
scheduledhandler. Execution time limits still apply, so long jobs need to be chunked or moved to a queue. - Netlify scheduled functions are UTC, with the standard function timeout — these are not the place for a job that takes minutes.
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:
- Use five-field expressions and add the seconds or year field only where required.
- Write day-of-week as names (
MON-FRI), which mean the same thing in every dialect, rather than numbers, which do not. - Never restrict day-of-month and day-of-week together — Unix reads it as OR, Quartz and AWS reject it.
- Avoid
@dailyand friends unless you have checked the target supports them. - Schedule in UTC unless a human is waiting, and avoid the 01:00–03:00 local window entirely.
- Offset off the top of the hour —
7 3 * * *rather than0 3 * * *— on any shared or managed platform.
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.