<< BACK

Cron — A Deep Dive

the Unix scheduler that outlived everything — its 1979 origin, five-field syntax, the OR vs AND trap, DST failure modes, and how every language reinvented it differently.

DATE:
AUG.22.2024
READ:
28 MIN

Every software system eventually needs to run something on a schedule. Send a report at 9 AM. Prune the database at midnight. Sync a feed every five minutes. The Unix answer to this has been the same since 1979: five whitespace-separated fields and a command. Four and a half decades later, it runs on billions of machines.

This is the complete story of cron — where it came from, every obscure syntax rule, the classic trap that burns people, how twelve different language ecosystems reimplemented it, and the distributed-systems problems that arise when you run more than one instance of anything.

There’s an interactive explorer at the bottom. Type any expression and it will parse it live, show next run times, and count down to the next tick.


History: from V7 Unix to Vixie

Ken Thompson’s original, 1975–1979

The name comes from Chronos, the Greek personification of time. The daemon was written at AT&T Bell Laboratories by Ken Thompson and shipped in Unix Version 7 (1979). The V7 implementation was deliberately minimal:

  • Woke exactly once per minute
  • Read a single global file: /usr/lib/crontab
  • Ran every matching command as root only
  • Slept until the next minute tick

No per-user tables. No spool directory. No security separation. Practical enough for a research system with a handful of users.

The multi-user problem: Purdue, 1979

When Unix spread to university computing centers — shared VAX systems with hundreds of users — the single-table design stopped working. Waking every minute and scanning all jobs for everyone imposed load that scaled badly.

Robert Brown, a Purdue graduate student, recognized that the Franta–Maly event queue algorithm (published in Communications of the ACM, August 1977) solved this. Instead of polling every minute blindly, pre-sort the next wake-up time and sleep until it. Keith Williamson turned Brown’s prototype into a production service; multi-user cron went live at Purdue in late 1979.

Williamson subsequently moved to AT&T Bell Labs in Murray Hill and integrated the per-user crontab model into System V, moving crontab files from a single global table to /var/spool/cron/crontabs/ with one file per user. POSIX 1003.2 (1992) codified the five-field syntax and the *, ,, - operators as the standard.

Vixie cron, 1987

Paul Vixie surveyed Unix users for pain points and released his reimplementation on May 6, 1987. The version history matters:

+----------------+-------------------+--------------------+
| Version        | Date              | Key addition       |
+----------------+-------------------+--------------------+
| 1.0            | May 6, 1987       | Initial release    |
+----------------+-------------------+--------------------+
| 2.0            | July 5, 1990      |                    |
+----------------+-------------------+--------------------+
| 2.2            | 1992              | POSIX-compliant    |
+----------------+-------------------+--------------------+
| 3.0pl1         | December 27, 1993 | Posted to          |
|                |                   | comp.sources.unix; |
|                |                   | became the         |
|                |                   | universal base     |
+----------------+-------------------+--------------------+
| 4.1 → ISC Cron | January 2004      | Stewardship        |
|                |                   | transferred to ISC |
+----------------+-------------------+--------------------+

Vixie 3.0pl1 is the ancestor of virtually every Linux cron running today. What Paul added that became permanent:

  • Per-user crontab files with crontab -e and file locking
  • SHELL=, MAILTO=, PATH= environment variable declarations
  • @reboot, @yearly, @monthly, @weekly, @daily, @hourly shortcut macros
  • The % character for passing stdin to commands
  • Security: jobs run as the owning user, not root

The modern fork landscape

+--------------------+--------------------+--------------------+--------------------+
| Fork               | Origin             | Default on         | Notable additions  |
+--------------------+--------------------+--------------------+--------------------+
| cronie             | Red Hat, 2007      | RHEL/Fedora/CentOS | PAM, SELinux,      |
|                    |                    |                    | inotify,           |
|                    |                    |                    | integrated anacron |
|                    |                    |                    | (2009)             |
+--------------------+--------------------+--------------------+--------------------+
| vixie-cron (Debian | Debian             | Debian/Ubuntu      | syslog, security   |
| patch)             |                    |                    | patches            |
+--------------------+--------------------+--------------------+--------------------+
| fcron              | Thibault Godouet   | Gentoo, optional   | Sub-day catch-up   |
|                    |                    |                    | for laptops that   |
|                    |                    |                    | sleep              |
+--------------------+--------------------+--------------------+--------------------+
| dcron              | Matt Dillon,       | DragonFly BSD      | Binary job DB,     |
|                    | DragonFly BSD      |                    | austere design, no |
|                    |                    |                    | env vars in        |
|                    |                    |                    | crontabs           |
+--------------------+--------------------+--------------------+--------------------+
| anacron            | standalone/cronie  | All                | Run missed         |
|                    |                    |                    | daily/weekly/month |
|                    |                    |                    | ly                 |
|                    |                    |                    |  jobs on next boot |
+--------------------+--------------------+--------------------+--------------------+
| mcron              | Dale Mellor, 2003  | Guix               | Jobs written in    |
|                    |                    |                    | Guile Scheme       |
|                    |                    |                    | instead of crontab |
|                    |                    |                    | syntax             |
+--------------------+--------------------+--------------------+--------------------+

anacron deserves special mention: it is not a cron replacement but a complement. It tracks the last time each job ran and fires it on next boot if the system was down when it was due. cronie absorbed anacron in 2009; every RHEL-family system has it built in.


The five-field syntax

++
┌───────────── minute        (0–59)
│ ┌───────────── hour          (0–23)
│ │ ┌───────────── day-of-month  (1–31)
│ │ │ ┌───────────── month        (1–12 or JAN–DEC)
│ │ │ │ ┌───────────── day-of-week  (0–6; both 0 and 7 = Sunday)
│ │ │ │ │
* * * * *  command to run
++

In /etc/crontab and files under /etc/cron.d/, a username field is inserted between the fifth field and the command:

++
0 2 * * *  root  /usr/sbin/logrotate /etc/logrotate.conf
++

The operators

* — all values

++
* * * * *   /usr/local/bin/heartbeat    # every minute
0 * * * *   /usr/local/bin/hourly-sync  # top of every hour
++

, — list

++
0 8,12,17 * * *  /usr/local/bin/check   # 8 AM, noon, 5 PM
0 0 * * 1,3,5   /usr/local/bin/report   # Mon, Wed, Fri
++

- — range

++
*/1 9-17 * * 1-5  /usr/local/bin/poll   # every minute, 9am–5pm weekdays
0 0 1-7  * 1      /usr/local/bin/first-monday  # 1st–7th AND Monday (Vixie OR trap — see below)
++

/ — step

This is a Vixie extension — not in POSIX. */N means “every N, starting from the field minimum.”

++
*/5  * * * *  /usr/local/bin/ping       # every 5 minutes
0  */3 * * *  /usr/local/bin/backup     # every 3 hours
0/15 9-17 * * 1-5  cmd                  # every 15 min, 9-5, weekdays
++

*/5 is syntactic sugar for the list 0,5,10,15,20,25,30,35,40,45,50,55. The form A-B/N restricts the step to the range A through B.

% — stdin (frequently trips people up)

++
0 9 * * 1  mail -s "Report" boss@example.com%See attached.%Regards
++

The first % ends the command and begins stdin. Subsequent % become newlines in stdin. Shell scripts containing date +%Y must escape as date +\%Y inside crontabs.

The @-shortcut macros

Introduced by Paul Vixie in 1987. @reboot is the most surprising: it fires when the cron daemon starts, not necessarily at kernel boot. Restarting crond mid-day re-fires all @reboot jobs.

+--------------------+------------+--------------------+
| Macro              | Equivalent | Note               |
+--------------------+------------+--------------------+
| @reboot            | (none)     | On cron daemon     |
|                    |            | start, not kernel  |
|                    |            | boot               |
+--------------------+------------+--------------------+
| @yearly /          | 0 0 1 1 *  | Jan 1 at midnight  |
| @annually          |            |                    |
+--------------------+------------+--------------------+
| @monthly           | 0 0 1 * *  | 1st of month,      |
|                    |            | midnight           |
+--------------------+------------+--------------------+
| @weekly            | 0 0 * * 0  | Sunday midnight    |
+--------------------+------------+--------------------+
| @daily / @midnight | 0 0 * * *  | Every midnight     |
+--------------------+------------+--------------------+
| @hourly            | 0 * * * *  | Top of every hour  |
+--------------------+------------+--------------------+

The seconds field (not in POSIX)

Standard Unix cron has no seconds field — the minimum granularity is one minute. Implementations that need sub-minute scheduling extend the format differently:

+--------------------+--------------------+--------------------+
| Implementation     | Position           | Example            |
+--------------------+--------------------+--------------------+
| cron (npm),        | Prepended as field | '*/30 * * * * *' = |
| node-cron          | 1                  | every 30s          |
+--------------------+--------------------+--------------------+
| robfig/cron (Go)   | Prepended, opt-in  | cron.New(cron.With |
| v3                 |                    | Seconds())         |
+--------------------+--------------------+--------------------+
| Quartz (Java)      | Prepended; year    | '0 15 10 * * ?' =  |
|                    | appended           | 10:15:00 AM daily  |
+--------------------+--------------------+--------------------+
| APScheduler        | 7-field: sec, min, | CronTrigger(second |
| (Python)           | hr, dom, mo, dow,  | ='*/10')           |
|                    | year               |                    |
+--------------------+--------------------+--------------------+
| tokio-cron-schedul | Prepended          | '1/10 * * * * *' = |
| er                 |                    | every 10s from :01 |
|  (Rust)            |                    |                    |
+--------------------+--------------------+--------------------+

The day-of-month + day-of-week trap

This is the single most commonly misunderstood behavior in cron, and Vixie himself commented on it in the source code:

“the dom/dow situation is odd. ‘` 1,15 Sun' will run on the first and fifteenth AND every Sunday; '* * * Sun' will run only on Sundays; ' 1,15 ' will run only the 1st and 15th. this is why we keepe->dow_starande->dom_star`. yes, it’s bizarre. like many bizarre things, it’s the standard.”

The rule: when both DOM and DOW are restricted (neither is *), the job runs on days matching either — OR semantics, not AND.

++
0 9 1 * 1   cmd
# Fires on: every 1st of the month  PLUS  every Monday
# NOT: "first Monday of each month"
++

The implementation tracks two boolean flags per entry: dom_star and dow_star. When dom_star is set (DOM was *), only DOW is tested. When dow_star is set, only DOM is tested. When neither is a wildcard, the OR union is taken. POSIX 2017 codified this explicitly.

+-------------+--------------------+
| Expression  | Meaning            |
+-------------+--------------------+
| 0 9 * * 1   | Every Monday       |
|             | (DOM=*, only DOW   |
|             | tested)            |
+-------------+--------------------+
| 0 9 1 * *   | Every 1st of month |
|             | (DOW=*, only DOM   |
|             | tested)            |
+-------------+--------------------+
| 0 9 1 * 1   | Every 1st of month |
|             | OR every Monday    |
+-------------+--------------------+
| 0 9 1-7 * 1 | Days 1–7 OR        |
|             | Mondays (not       |
|             | 'first Monday')    |
+-------------+--------------------+

Which implementations use which behavior:

+--------------------+--------------------+
| Implementation     | DOM+DOW behavior   |
+--------------------+--------------------+
| Vixie cron,        | OR (union) —       |
| cronie, dcron,     | standard           |
| fcron, BSD cron    |                    |
+--------------------+--------------------+
| robfig/cron (Go)   | OR — corrected in  |
| v3                 | v3; v1/v2 had AND  |
|                    | bug                |
+--------------------+--------------------+
| Celery Beat        | AND — opposite of  |
|                    | standard cron      |
+--------------------+--------------------+
| Quartz (Java)      | Requires ? in one  |
|                    | field; ambiguity   |
|                    | disallowed         |
+--------------------+--------------------+
| AWS EventBridge    | Requires ? in one  |
|                    | field; enforced at |
|                    | parse time         |
+--------------------+--------------------+
| cron-parser (npm)  | Validation error   |
| strict mode        | if both specified  |
+--------------------+--------------------+

Celery Beat’s reversal is especially dangerous if you’re mentally translating cron strings from a Linux crontab. day_of_week='fri', day_of_month='15-21' in Celery means “Friday AND between the 15th and 21st” — which is close to “third Friday of the month.” In standard cron the same expression would mean “any Friday OR any day between the 15th and 21st.”

Getting “first Monday of the month” correctly

In standard cron, you can’t express this cleanly. The workarounds:

++
# Option 1: shell gate in the job itself
0 9 1-7 * 1  [ "$(date +%u)" = "1" ] && cmd

# Option 2: Quartz # extension
0 9 ? * 2#1   # second field from left=DOW, 2=MON, #1=first occurrence

# Option 3: AWS EventBridge
cron(0 9 ? * 2#1 *)
++

Language bindings: the ecosystem

Every popular language has reinvented cron at least twice. The differences cluster around three axes: seconds support, timezone handling, and persistence.

JavaScript / Node.js

node-cron — 6-field format, seconds first. No Quartz extensions (L, W, #). In-memory only.

++
import cron from 'node-cron';
cron.schedule('*/30 * * * * *', handler, { timezone: 'UTC' });
//             └── seconds field
++

cron (npm, kelektiv) — also 6-field with seconds first. Accepts Date objects as cronTime. DST edge cases have been reported in issue #56 — spring-forward can cause a job to fire one hour late.

++
import { CronJob } from 'cron';
const job = CronJob.from({
  cronTime: '0 */5 * * * *',  // every 5 minutes at second 0
  onTick: handler,
  timeZone: 'America/New_York',
  start: true,
});
++

BullMQ — not a cron library; a full job queue backed by Redis. Repeatable jobs accept a cron string. The key advantage: workers run across multiple processes — horizontal scalability without thundering herd. Exactly-once per scheduled tick via Redis atomic operations.

++
await queue.add('report', data, {
  repeat: { pattern: '0 9 * * 1-5', tz: 'UTC' }
});
++

Agenda — MongoDB-backed; survives process restarts. Multiple Agenda instances coordinate via MongoDB optimistic locking. Has a web dashboard (Agendash). MongoDB is a hard dependency — heavyweight for simple use cases.

Python

APScheduler (Advanced Python Scheduler) — the most complete Python scheduler. The CronTrigger accepts 7 fields: second, minute, hour, day, month, day_of_week, year. Accepts from_crontab() to parse standard 5-field strings.

++
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger

scheduler = BlockingScheduler()
# Parse standard cron string
trigger = CronTrigger.from_crontab('0 9 * * 1-5', timezone='America/New_York')
scheduler.add_job(my_func, trigger)
# Or use keyword fields
scheduler.add_job(fn, 'cron', hour='*/3', minute=0, timezone='UTC')
++

DST behavior: APScheduler advances to the next valid time after a gap. The official docs recommend UTC for production.

Celery Beat — scheduler process for Celery task queues. Uses crontab() keyword arguments. Two critical gotchas:

  1. AND semantics for DOM + DOW (opposite of standard cron)
  2. Single Beat process required — running multiple Beat instances causes duplicate job execution
++
from celery.schedules import crontab
app.conf.beat_schedule = {
    'daily': { 'task': 'tasks.daily', 'schedule': crontab(hour=7, minute=30) },
    'every-3h': { 'task': 'tasks.poll', 'schedule': crontab(minute=0, hour='*/3') },
}
++

schedule — minimalist human-readable DSL, no cron syntax, no persistence, no timezone. Fine for scripts; not production schedulers.

Go

robfig/cron v3 — the standard Go cron library. Defaults to 5-field. Seconds opt-in via WithSeconds(). Per-entry timezone via CRON_TZ= prefix. The @every interval extension (@every 1h30m) is unique to this library.

++
c := cron.New(cron.WithSeconds())
c.AddFunc("0 */5 * * * *", func() { log.Println("every 5 minutes") })

c2 := cron.New()
c2.AddFunc("CRON_TZ=America/New_York 30 9 * * 1-5", weekdayJob)
c2.AddFunc("@every 2h30m", intervalJob)
c2.Start()
++

Breaking change from v1 to v3: v1 used AND semantics for DOM+DOW (a bug). v3 corrects to OR. If you’re upgrading a codebase with expressions combining both fields, audit every one.

gocron v2 — higher-level fluent API. Native distributed mode via an Elector interface (pluggable: Postgres, Redis) to elect a single scheduler leader across instances. Job-level Locker for exactly-one-at-a-time execution.

Java: Quartz

Quartz is the canonical Java scheduler. Latest stable: 2.3.2 (2019). Its 7-field expression is the most expressive in common use:

++
Seconds  Minutes  Hours  Day-of-Month  Month  Day-of-Week  [Year]
  0        15      10         ?          *       MON-FRI
++

The special characters unique to Quartz:

CharMeaningExample
?No specific value (required in DOM or DOW, not both)0 0 12 ? * MON-FRI
LLast — last day of month (DOM) or last occurrence of DOW0 0 0 L * ? = last day of month
WNearest weekday0 0 8 15W * ? = nearest weekday to the 15th
LWLast weekday of month0 0 8 LW * ?
#Nth occurrence of DOW0 0 9 ? * 6#3 = third Friday

Quartz DOW numbering: 1=SUN, 2=MON, …, 7=SAT — not 0-based like Unix cron.

Ruby

whenever — DSL that writes to the actual system crontab via whenever --update-crontab. Deploys via Capistrano integration. Jobs actually run via the OS cron daemon.

++
# config/schedule.rb
every 3.hours { runner "Model.process" }
every 1.day, at: '4:30 am' { script "heavy_lifting" }
every :monday, at: '9am' { rake "reports:weekly" }
every '0 */6 * * *' { command "/usr/bin/my_cmd" }  # raw cron also accepted
++

rufus-scheduler — in-process Ruby scheduler using threads. No persistence; jobs die with the process. Supports standard 5-field cron strings with timezone suffix.

Rust

tokio-cron-scheduler — async scheduler for the Tokio runtime. 6-field cron (seconds first) via the croner crate. Optional PostgreSQL or NATS persistence backends. Optional English-language schedule parsing ("every 15 seconds") via a feature flag.

++
use tokio_cron_scheduler::{JobScheduler, Job};

let sched = JobScheduler::new().await?;
sched.add(Job::new("0/10 * * * * *", |_uuid, _l| {
    println!("every 10 seconds");
})?).await?;
sched.start().await?;
++

systemd timers

systemd timers have been mature since systemd 197 (2012). Two unit files per task (.service + .timer), but three advantages over cron that matter at scale:

Persistent=true — catches up missed jobs on next boot (built-in anacron behavior, no separate daemon needed).

RandomizedDelaySec — adds a random jitter to prevent thundering herd. Built into the scheduler, not something you implement in the job.

AccuracySec — allows energy-saving coalescing of timers.

++
# /etc/systemd/system/daily-backup.timer
[Unit]
Description=Daily backup timer

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target
++

The OnCalendar syntax is more expressive than cron for calendar arithmetic:

++
OnCalendar=Mon..Fri *-*-* 09:00:00   # weekdays 9am
OnCalendar=*-*-1/2 *:00:00           # every other day, every hour
OnCalendar=quarterly                  # 0 0 1 Jan,Apr,Jul,Oct *
++

systemd-analyze calendar 'Mon..Fri *-*-* 09:00:00' tells you the next 10 times the expression fires — the cron equivalent of this has no built-in tool.


Distributed cron problems

The thundering herd

On multi-instance deployments — Kubernetes pods, ECS tasks, Docker Swarm — every instance runs its own in-process scheduler. When the minute ticks over, all instances fire simultaneously, hitting the same database or external API at once.

Failure modes:

  • Duplicate data processing (idempotency violations)
  • Database write conflicts and row locking storms
  • External API rate limit exhaustion
  • Duplicate notifications to users

Leader election patterns

Redis SET NX EX

++
const acquired = await redis.set(
  'cron:daily-report:lock', instanceId, 'NX', 'EX', 120
);
if (acquired !== 'OK') return;  // another instance won
try {
  await runJob();
} finally {
  // Lua compare-and-delete — don't delete if another instance refreshed the key
  const script = `if redis.call("get",KEYS[1])==ARGV[1] then
    return redis.call("del",KEYS[1]) else return 0 end`;
  await redis.eval(script, 1, 'cron:daily-report:lock', instanceId);
}
++

Pitfall: if the job runs longer than the TTL, the lock expires mid-execution. Use a heartbeat renewal pattern for long-running jobs.

PostgreSQL advisory locks

++
-- Non-blocking; returns true only if lock acquired
SELECT pg_try_advisory_lock(hashtext('daily-report'));
-- Lock automatically released when session ends — built-in safety property
++

Kubernetes CronJob

Sidesteps application-level leader election entirely — the control plane creates exactly one Job per schedule tick. concurrencyPolicy: Forbid prevents concurrent runs. startingDeadlineSeconds prevents a backlog of missed runs from firing all at once after an outage.

++
spec:
  schedule: "0 9 * * 1-5"
  timeZone: "America/New_York"   # stable since Kubernetes 1.27
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 300
++

The 100-missed-schedule cliff

If a Kubernetes CronJob controller has been down and comes back with more than 100 missed schedule ticks, it will refuse to catch up and log an error. Setting startingDeadlineSeconds prevents this by narrowing the window of “missed” schedules to check.

At-least-once vs exactly-once

No off-the-shelf scheduler provides true exactly-once execution. The closest you can get:

+--------------------+--------------------+--------------------+
| Approach           | Semantics          | Requires           |
+--------------------+--------------------+--------------------+
| Kubernetes CronJob | At-most-once per   | Kubernetes         |
| + Forbid           | run window         |                    |
+--------------------+--------------------+--------------------+
| Redis SET NX +     | At-most-once (lock | Redis + job        |
| idempotent job     | holder only)       | idempotency        |
+--------------------+--------------------+--------------------+
| BullMQ repeatable  | At-least-once per  | Redis 6.2+         |
| jobs               | tick, no duplicate |                    |
|                    | ticks              |                    |
+--------------------+--------------------+--------------------+
| DB advisory lock + | Closest to         | DB + idempotency   |
| job state machine  | exactly-once       | keys + state       |
|                    |                    | tracking           |
+--------------------+--------------------+--------------------+

Timezone traps

The spring-forward gap (2:00 AM → 3:00 AM)

When US clocks spring forward, 2:00–2:59 AM simply does not exist. A job at 30 2 * * * has no valid time that day.

+--------------------+--------------------+
| Implementation     | Spring-forward     |
|                    | behavior           |
+--------------------+--------------------+
| Debian/Ubuntu cron | Fixed-time jobs    |
|                    | run at 3:00 AM     |
|                    | boundary; wildcard |
|                    | jobs skip          |
+--------------------+--------------------+
| RHEL/cronie        | Job skipped        |
|                    | entirely           |
+--------------------+--------------------+
| APScheduler        | Advances to 3:00   |
|                    | AM (next valid     |
|                    | time after the     |
|                    | gap)               |
+--------------------+--------------------+
| node-cron          | Skips — no DST     |
|                    | compensation       |
+--------------------+--------------------+
| robfig/cron (Go)   | Skips the          |
|                    | nonexistent time   |
+--------------------+--------------------+

Debian’s behavior is documented by healthchecks.io — the implementation inspects whether the minute/hour specifier starts with * (wildcard) or a fixed value, and gates the catch-up behavior accordingly.

The fall-back repeat (2:00 AM → 1:00 AM)

When clocks fall back, 1:00–1:59 AM occurs twice. A job at 30 1 * * * could fire twice.

+--------------------+--------------------+
| Implementation     | Fall-back behavior |
+--------------------+--------------------+
| Debian/Ubuntu cron | Fires once         |
|                    | (protects against  |
|                    | backward jumps < 3 |
|                    | hours)             |
+--------------------+--------------------+
| RHEL/cronie        | May fire once per  |
|                    | local-clock        |
|                    | occurrence         |
|                    | (potentially       |
|                    | twice)             |
+--------------------+--------------------+
| APScheduler        | Fires once, tracks |
|                    | wall-clock         |
|                    | progress           |
+--------------------+--------------------+
| Celery Beat        | Can double-fire    |
|                    | depending on       |
|                    | broker clock       |
+--------------------+--------------------+

The only safe rule

Schedule everything in UTC. Handle local-time display in application code.

Implementations that support timezone configuration:

++
# vixie-cron / cronie — at the top of the user's crontab
CRON_TZ=America/New_York
30 9 * * 1-5  /usr/local/bin/morning-report

# robfig/cron — per-entry prefix
c.AddFunc("CRON_TZ=America/New_York 30 9 * * 1-5", job)

# Kubernetes CronJob — .spec.timeZone (stable since 1.27)
spec:
  timeZone: "Europe/London"
++

GitHub Actions schedule: UTC only. No timezone support planned. Also: minimum interval */5 * * * *; expect 5–30 minute delays at peak load; workflows disabled after 60 days of inactivity if schedule is the only trigger.


Extended formats

AWS EventBridge (6 fields, cron() wrapper)

EventBridge wraps its expressions in cron() and adds a required year field. Day-of-week uses 1=SUN through 7=SAT. Either DOM or DOW must be ? — the ambiguity is disallowed.

++
cron(minute  hour  day-of-month  month  day-of-week  year)

cron(0 10 * * ? *)         # 10 AM UTC every day
cron(0 12 ? * MON-FRI *)   # noon UTC every weekday
cron(0 9 ? * 2#1 *)        # first Monday at 9 AM (2=MON, #1=first)
cron(0 8 15W * ? *)        # 8 AM, nearest weekday to the 15th
cron(0 0 L * ? *)          # last day of month at midnight
++

All times are UTC only. No timezone configuration.

Syntax comparison at a glance

+--------------------+------------+-----------+-----------+----------------+-------+----------------+
| System             | Fields     | Seconds   | Year      | ?              | L/W/# | Timezone       |
+--------------------+------------+-----------+-----------+----------------+-------+----------------+
| Unix/POSIX cron    | 5          | No        | No        | No             | No    | CRON_TZ var    |
+--------------------+------------+-----------+-----------+----------------+-------+----------------+
| Quartz (Java)      | 6–7        | Yes (1st) | Yes (7th) | Yes (required) | Yes   | Per-trigger    |
+--------------------+------------+-----------+-----------+----------------+-------+----------------+
| robfig/cron v3     | 5 or 6     | Opt-in    | No        | No             | No    | CRON_TZ prefix |
| (Go)               |            |           |           |                |       |                |
+--------------------+------------+-----------+-----------+----------------+-------+----------------+
| node-cron / cron   | 6          | Yes (1st) | No        | No             | No    | Option         |
| (npm)              |            |           |           |                |       |                |
+--------------------+------------+-----------+-----------+----------------+-------+----------------+
| APScheduler        | 7          | Yes       | Yes       | No             | No    | Per-trigger    |
| (Python)           |            |           |           |                |       |                |
+--------------------+------------+-----------+-----------+----------------+-------+----------------+
| Celery Beat        | 5 (kwargs) | No        | No        | No             | No    | App-level      |
+--------------------+------------+-----------+-----------+----------------+-------+----------------+
| AWS EventBridge    | 6          | No        | Yes (6th) | Required       | Yes   | UTC only       |
+--------------------+------------+-----------+-----------+----------------+-------+----------------+
| GitHub Actions     | 5          | No        | No        | No             | No    | UTC only       |
+--------------------+------------+-----------+-----------+----------------+-------+----------------+
| Kubernetes CronJob | 5          | No        | No        | No             | No    | .spec.timeZone |
+--------------------+------------+-----------+-----------+----------------+-------+----------------+
| systemd timers     | Calendar   | Yes       | Yes       | N/A            | N/A   | Full IANA      |
+--------------------+------------+-----------+-----------+----------------+-------+----------------+

Interactive explorer

Type any cron expression below — standard 5-field, @hourly, @reboot, anything. The explorer parses every field, describes what it means, computes the next five run times, and counts down to the next tick. The OR semantics for day-of-month + day-of-week are implemented correctly — try 0 9 1 * 1 and watch it fire on both the 1st of every month and every Monday.

*/5 minute every 5 minutes
9-17 hour 9–17
* day of month every day of month
* month every month
1-5 day of week MON–FRI
next runs
1 Mon 2026-05-04 09:00
2 Mon 2026-05-04 09:05
3 Mon 2026-05-04 09:10
4 Mon 2026-05-04 09:15
5 Mon 2026-05-04 09:20
next run in
00 h
:
00 m
:
00 s

What actually breaks in production

The % sign. You write date +%Y in a cron command and it silently becomes stdin. The job produces no output and you spend an hour confused. Always escape: date +\%Y.

@reboot firing on daemon restart. You restart crond during a deploy. Every @reboot job re-runs mid-day. Use a flag file or systemd’s ConditionPathExists to gate one-shot jobs.

GitHub Actions drift. You set */5 * * * * on a workflow and notice it fires every 15–30 minutes during business hours. This is not a bug in your expression — it’s GitHub shedding load. If timing precision matters, don’t use GitHub Actions schedule.

Celery Beat duplication. You scale your Celery worker fleet to three nodes and start a Beat process on each “for redundancy.” All three fire every scheduled task three times. Beat must run as a single instance. Use the django-celery-beat database backend with a single Beat process per deployment, or use a distributed lock.

The 100-schedule cliff in Kubernetes. Your CronJob has been suspended for maintenance. You unsuspend it. Kubernetes sees 150 missed schedules, logs an error, and runs nothing. Add startingDeadlineSeconds: 300 to every CronJob.

Timezone on the 1st at 2:30 AM. You schedule a monthly job at 30 2 1 * * in a US/Eastern timezone. In March, that time doesn’t exist. cronie skips it. Your monthly job doesn’t run. In November, it runs at 1:30 AM twice. Use UTC for scheduled times without exception.