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
| 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 |
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
- 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. You will only notice this failure mode if you are watching the controller logs.- Not exactly-once. The documentation is explicit that a CronJob may create two Jobs, or none, for a single schedule. The job itself must therefore be idempotent.
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. 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
- The year field is required for Rules. Rules need six fields rather than five, so 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, which follows the Quartz numbering rather than the Unix one.
- UTC only for Rules. EventBridge Scheduler, a newer and 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 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 * * *"
- 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 run in UTC and are subject to the standard function timeout, so they are a poor fit for a job that takes minutes.
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:
- 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, while 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.
- On any shared or managed platform, move the schedule off the top of the hour (
7 3 * * *rather than0 3 * * *).
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.