A cron line survives a copy and paste into another system. That is the trap. It does not fail the way a typo fails, with a parser complaining on save. It fails later, on the wrong day, in a log nobody is reading.
The five fields are the part that travels. Everything around them is where the platforms part company, and they make four decisions differently: where the week starts, whether ? is mandatory, which clock the expression is read against, and what happens when the previous run is still going. The field reference lists the formats side by side. What follows is why each difference bites.
The part that travels
POSIX crontab, GitHub Actions and the Kubernetes CronJob all take the same five fields in the same order: minute, hour, day of month, month, day of week. If the expression you have is 0 9 * * 1-5 and the target is one of those three, the schedule survives the move and the five-field reading still applies.
Two of the four use a six-field layout, and they are not the same six. Quartz puts seconds in front and shifts everything right; AWS appends a year at the end. Both keep the rest of the order intact, which is what makes the mistake easy: the expression reads as familiar for four or five fields and then diverges at the edge.
It is also why "cron expression must consist of 6 fields" is an error message people go looking for. It comes from a Java scheduler, Quartz or Spring's @Scheduled, which expects seconds first, and it shows up when someone hands it the five fields they copied out of a crontab.
GitHub Actions: UTC, and a floor of five minutes
The schedule trigger takes five fields and reads them in UTC. Not the runner's timezone, not the timezone of whoever wrote the workflow file. The GitHub documentation is flat about it: "By default, scheduled workflows run in UTC." A workflow written as 0 9 * * 1-5 to hit 9am for a European team fires at 09:00 UTC, which is 10:00 or 11:00 locally depending on the season. You can set an IANA timezone instead, and that is the fix, but the default catches people because nothing in the YAML says UTC.
The minimum interval is five minutes: "The shortest interval you can run scheduled workflows is once every 5 minutes." An expression like */2 * * * * is accepted and then rounded up to that floor, which reads as a schedule ignoring you rather than one that errors.
The third difference is load, and it is the one to plan around. GitHub's own documentation warns that "the schedule event can be delayed during periods of high loads of GitHub Actions workflow runs", and names the worst slot: "High load times include the start of every hour." So 0 * * * *, every hour on the hour, sits on the most contended minute of the platform. If the job is not time-critical, moving it to 7 * * * * costs nothing and steps out of the queue.
Aliases do not exist here. @daily and @weekly are a Vixie cron extension and the schedule trigger rejects them.
Kubernetes: a job that overlaps itself
A CronJob takes the same five fields. What differs is the clock and what the controller does about concurrency.
The clock defaults to the time zone of the kube-controller-manager process, which in a standard container means UTC. A timeZone field on the job overrides it and takes an IANA name. The API server checks that name against the system time zone database when you apply the job, so a typo is rejected there rather than scheduled silently. If a zone that was valid stops resolving later, after a host tzdata change for instance, the controller stops creating Jobs for that entry and records an UnknownTimeZone event; it never falls back to UTC.
The concurrency default is the part worth reading twice. In the CronJob API, concurrencyPolicy defaults to Allow: if the previous run is still going when the next one is due, the controller starts another Job alongside it. A job that takes eight minutes on a five-minute schedule quietly builds a pile of overlapping runs. The alternatives are explicit:
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid # skip the run if the last one is still going
Forbid skips the new run; Replace cancels the running one and starts fresh. Neither is the default. A plain crontab has the same overlap, except there it is your script's problem and the usual answer is a lock file.
AWS: six fields and a ? you cannot skip
An EventBridge cron expression has six required fields, and the sixth is the year, from 1970 to 2199. That is the first thing that breaks a copy from a crontab, and at least it is the visible one.
The day-of-week numbering is the quiet one. In a Unix crontab, Sunday is both 0 and 7, and 1 is Monday. In EventBridge the field runs 1-7 with 1 at Sunday, so the numbering is shifted by one and 0 is out of range. The AWS documentation's own example settles the direction: cron(15 10 ? * 6L 2019-2022) is the last Friday of the month, which puts 6 at Friday and 1 at Sunday. A line copied as 0 12 * * 1 to mean Monday asks AWS for Sunday.
Then there is the ?. It means "any value", and it is not decorative. The AWS cron reference refuses to take a value in both day fields at once: "You can't use * in both the Day-of-month and Day-of-week fields. If you use it in one, you must use ? in the other." The line people reach for, cron(0 12 * * MON-FRI *), mixing * in day-of-month with a range in day-of-week, is rejected. One of the two has to be ?. This is also why the two day fields are not ORed here the way POSIX ORs them: AWS made you pick one and blank the other, so there is nothing left to combine.
The wildcards L, W and # are supported, which is where EventBridge lines up with Quartz and not with Linux. L is the last day of the month or of the week, W is the weekday nearest a given date, # is the nth occurrence of a weekday. Only # carries an extra restriction: one expression per field, so 3#1,6#3 is invalid.
On time zones the two EventBridge products disagree with each other. Rules are evaluated in UTC and cannot be given a zone at all. Scheduler takes --schedule-expression-timezone with an IANA name. Moving a job between the two changes when it fires even when the expression is character for character the same.
Quartz: seconds in front
Quartz is the format behind most Java scheduling and the source of both the six-field shape and the #. The Quartz CronTrigger reference has six fields plus an optional seventh for the year, and the first one is seconds:
0 30 8 ? * MON-FRI
│ │ │ │ │ └─ day of week
│ │ │ │ └───── month
│ │ │ └──────── day of month
│ │ └────────── hour
│ └───────────── minute
└──────────────── second
That is also the answer to "cron with seconds". A Linux crontab has no seconds field and never will, because the daemon wakes once a minute. If you need a sub-minute schedule, you are on Quartz or Spring, not on cron.
? works the way it does on AWS and for the same reason: Quartz does not fully support naming day-of-month and day-of-week at the same time, so one of the two holds ?. The operators that a Linux crontab has no way to express are right here, including the one that sends people looking for "first Monday of the month":
0 0 4 ? * 1#1 first Monday of the month, 04:00
0 0 4 L * ? last day of the month, 04:00
The same first Monday needs a date guard on a plain crontab, which is covered in cron expression examples.
One job, written where it has to run
Take a single intent, 04:00 on the first Monday of every month, and write it on each platform:
| Target | Expression | What changes |
|---|---|---|
| Crontab (Linux) | 0 4 * * 1 [ "$(date +\%d)" -le 7 ] && job |
The schedule cannot say "first Monday"; a shell guard does |
| GitHub Actions | 0 4 * * 1 |
No first-Monday support either, and the clock is UTC |
| Kubernetes | 0 4 * * 1 + timeZone |
Same limit, and concurrencyPolicy decides the overlap |
| AWS EventBridge | cron(0 4 ? * 2#1 *) |
Six fields, ? mandatory, and 1 is Sunday so Monday is 2 |
| Quartz | 0 0 4 ? * 1#1 |
Seconds in front, year field optional |
Three of the five expressions do not say what the sentence says. The two that do, Quartz and EventBridge, use an operator that Linux cron has never had.
What to check before the line moves
None of this makes the five fields wrong. It makes them incomplete: the fields are the portable part, and the four decisions around them are the part to verify whenever an expression crosses a boundary, whether that boundary is a different product, a different time zone or a different team's cluster.
The cron parser on this site lays an expression out field by field, which is the cheapest way to see that the 1 you wrote is Monday in one place and Sunday in another.