Cron syntax compared: standard, Quartz, node-cron and EventBridge

Four dialects that look interchangeable and are not. A field-by-field comparison, and the two differences that make a copied expression run at the wrong time.

Cron expressions are not a standard. They are a family of related syntaxes that diverged, and the divergences are exactly the kind that parse cleanly and behave differently. This page is about the two that actually cause incidents.

At a glance

FeatureStandardnode-cronQuartzEventBridge
Fields55 or 66 or 76
SecondsNoYes, leadingYes, leadingNo
YearNoNoOptionalRequired
Sunday is0 or 70 or 711
? requiredNoNoYesYes
L W #NoNoYesYes
@daily shorthandsYesYesNoNo
Default timezoneServer localProcess localJVM defaultUTC

Trap 1: field count

Take “every Monday at 09:00” and move it between systems without adjusting it:

0 9 * * 1        # standard cron: 09:00 on Mondays  ✓

# pasted into node-cron, which reads six fields when given six:
0 9 * * 1        # five fields — still read as standard cron  ✓
0 0 9 * * 1      # what you need if you write six  ✓

# pasted into Quartz:
0 9 * * 1        # five fields — rejected outright  ✗

Being rejected is the good outcome. The dangerous case is an expression that has the right number of fields for both dialects and means different things:

0 0 9 * * 1

# node-cron:  second 0, minute 0, hour 9, any day, any month, Monday
#             → 09:00 on Mondays
# Quartz:     rejected — needs ? in one of the two day fields

Trap 2: day-of-week numbering

This is the one that gets to production, because nothing rejects it.

DayStandard / node-cronQuartz / EventBridge
Sunday0 or 71
Monday12
Tuesday23
Wednesday34
Thursday45
Friday56
Saturday67

The fix is to use names, which mean the same thing everywhere: 0 9 * * MON-FRI and 0 0 9 ? * MON-FRI are both unambiguous, and all four dialects accept them.

Trap 3: the OR rule

In standard cron and node-cron, when both day fields are restricted, the job runs when either matches:

0 9 1 * 1

# reads like:  "09:00 on the 1st, if it is a Monday"
# actually is: "09:00 on the 1st, OR 09:00 on any Monday"
#              → about five runs a month, not one

This is inherited from Vixie cron and is unlikely ever to change. Quartz and EventBridge refuse to reproduce it: they require ? in the field you are not using, so the ambiguity cannot arise. That is why their expressions look cluttered.

The same schedule, four ways

ScheduleStandardnode-cronQuartzEventBridge
Every 5 minutes*/5 * * * *0 */5 * * * *0 0/5 * * * ?*/5 * * * ? *
Daily at 09:000 9 * * *0 0 9 * * *0 0 9 * * ?0 9 * * ? *
Weekdays at 09:000 9 * * 1-50 0 9 * * 1-50 0 9 ? * 2-60 9 ? * 2-6 *
Last day of the month0 23 28-31 * * ⚠0 0 23 28-31 * * ⚠0 0 23 L * ?0 23 L * ? *
First Monday of the month0 9 1-7 * 1 ⚠0 0 9 1-7 * 1 ⚠0 0 9 ? * 2#10 9 ? * 2#1 *

⚠ marks an approximation. Standard cron cannot express “last day” — 28-31 fires on up to four days — and it cannot express “first Monday” either: 1-7 plus day 1 triggers the OR rule and fires on the first seven days and every Monday. Both need a guard inside the job:

# first Monday only, from a 1-7 * 1 schedule
[ "$(date +%u)" = "1" ] || exit 0

Which one am I using?

If you are writing…The dialect is
crontab -eStandard
Kubernetes CronJobStandard
GitHub ActionsStandard, always UTC
Spring @Scheduled(cron = …)Quartz-style, six fields
Quartz SchedulerQuartz
node-cron npm packagenode-cron
EventBridge rule or cron(…) in CloudFormationEventBridge
systemd timerNot cron at all — OnCalendar has its own syntax

A checklist for moving an expression

  1. Count the fields the target expects, and pad or trim accordingly.
  2. If either day field is set, convert the numbering — or replace it with MON-style names and stop worrying.
  3. Moving to Quartz or EventBridge: put ? in the day field you are not using.
  4. Moving from Quartz: L, W and # have no equivalent. Move that logic into the job.
  5. Check the target’s default timezone. It is probably not yours.
  6. Read the next few run times before deploying — the generator shows eight, and will tell you when your expression is also valid in another dialect where it means something else.

Frequently asked questions

Why does my cron expression work in one system and not another?

Almost always the field count. Standard cron takes five fields, node-cron takes five or six, Quartz takes six or seven, and EventBridge takes exactly six. Add or remove a field and every value shifts position — an hour becomes a day-of-month, a day becomes a month.

Is Monday 1 or 2?

Both, depending. Standard cron and node-cron number Sunday as 0 (and also accept 7), so Monday is 1. Quartz and EventBridge number Sunday as 1, so Monday is 2. This is the difference that causes a copied expression to run a day early while parsing perfectly.

Why does Quartz need a question mark?

Because it refuses to inherit Unix cron’s OR rule, where restricting both day fields means "either matches". Quartz makes you say which of the two you are using by putting ? in the other. It is more verbose and less ambiguous.

Can I just use names instead of numbers?

Where the scheduler accepts them, yes, and you should. MON, TUE and JAN, FEB mean the same thing in every dialect, which removes the numbering trap entirely. Standard cron, node-cron, Quartz and EventBridge all accept the three-letter names.

Found a mistake on this page? Tell me — a page that is confidently wrong is worse than no page.