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 -eand file locking SHELL=,MAILTO=,PATH=environment variable declarations@reboot,@yearly,@monthly,@weekly,@daily,@hourlyshortcut 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 runIn /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.confThe 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.%RegardsThe 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 fieldcron (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:
- AND semantics for DOM + DOW (opposite of standard cron)
- 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-FRIThe special characters unique to Quartz:
| Char | Meaning | Example |
|---|---|---|
? | No specific value (required in DOM or DOW, not both) | 0 0 12 ? * MON-FRI |
L | Last — last day of month (DOM) or last occurrence of DOW | 0 0 0 L * ? = last day of month |
W | Nearest weekday | 0 0 8 15W * ? = nearest weekday to the 15th |
LW | Last weekday of month | 0 0 8 LW * ? |
# | Nth occurrence of DOW | 0 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 acceptedrufus-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.targetThe 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 propertyKubernetes 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: 300The 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 midnightAll 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.
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.