Skip to main content
Toolsbase Logo

Cron Expression Guide: Syntax, Patterns, Parser Libraries & Pitfalls

Toolsbase Editorial Team
CronCrontabSchedulingLinuxKubernetesGitHub ActionsQuartz

The Cron Tax: Why Five Fields Cause So Much Damage

Almost every team that runs scheduled jobs has at least one war story about cron: a backup that quietly stopped running for three months because 0 0 30 2 * can never trigger; a Kubernetes CronJob that fired every minute when the operator meant every hour; a GitHub Actions schedule that ran at 6 AM Eastern when the team thought they had said 6 AM Pacific. Cron's syntax is famously terse — five fields and a handful of special characters — but the terseness hides a number of decisions that have to be exactly right for the schedule to do what you intended.

This guide walks through the cron syntax that virtually every Linux system, container runtime, and CI platform inherits from Bell Labs in the 1970s. We'll cover the five fields, the special characters and their less-obvious interactions, the gap between UNIX cron and Quartz Scheduler that catches Java developers, the parser libraries available in each major language, and the recurring pitfalls that show up in production. Every example below can be pasted into the Cron Parser tool to see the next ten execution times immediately.

A Brief History: From Bell Labs to Kubernetes

The original cron was written by Brian Kernighan at Bell Labs in 1975 for Version 7 Unix. Paul Vixie rewrote it in 1987 as Vixie cron, which became the basis for the implementations shipped in BSD, Linux distributions, and macOS. The POSIX specification (IEEE Std 1003.1) standardised the user interface (crontab command) but left some behavioural details — especially the day-of-month and day-of-week interaction — implementation-defined.

In the early 2000s, the Java community produced Quartz Scheduler, which extended the five-field format with a sixth field for seconds and added special characters like L (last), W (weekday nearest), and # (nth weekday of the month). Spring's @Scheduled(cron = "...") annotation uses Quartz's six-field dialect. This is the single largest source of confusion when people copy expressions between platforms — a six-field Spring expression pasted into a Linux crontab will fail silently.

Modern container schedulers — Kubernetes CronJob, Vercel Cron, GitHub Actions — all use the UNIX five-field format. Most also fix the timezone to UTC by default, which is itself a frequent source of surprise.

The Five Fields

A standard UNIX cron expression has five fields, separated by whitespace, in the following order:

* * * * *
│ │ │ │ │
│ │ │ │ └─ Day of week (0-6, where 0 = Sunday; also SUN-SAT)
│ │ │ └─── Month (1-12; also JAN-DEC)
│ │ └───── Day of month (1-31)
│ └─────── Hour (0-23)
└───────── Minute (0-59)

Each field accepts the wildcard *, single values, ranges, lists, and steps. Some implementations accept 7 as a synonym for Sunday in the day-of-week field; both Vixie cron and our parser do, normalising 7 to 0 automatically.

The field ordering — minute first, weekday last — is by far the most-failed quiz question for new operators. A useful mnemonic: "the smaller units come first." Minutes are smaller than hours are smaller than days, and weekday hangs off the side because it's a different kind of axis.

Special Characters

Five characters carry meaning beyond their literal value. Knowing all five is enough to read any UNIX cron expression in the wild.

Asterisk (*)

* means "every value in this field's range." * * * * * therefore means "every minute of every hour of every day of every month of every weekday" — i.e., every minute. There is no separate "always" character; an asterisk in a field is the only way to say "I don't care about this field."

Comma (,) — Lists

A comma lists discrete values: 0,15,30,45 * * * * runs four times an hour, at the top, quarter past, half past, and quarter to. Lists can be combined with ranges: 0,30 9-17 * * 1-5 runs at the top and bottom of every hour from 9 AM to 5 PM on weekdays.

Hyphen (-) — Ranges

A hyphen is an inclusive range. 9-17 in the hour field means 9 AM through 5 PM (inclusive of both endpoints). Ranges wrap is not supported in standard cron — 22-2 in the hour field will not mean "10 PM through 2 AM." You have to write 0-2,22-23 instead.

Slash (/) — Steps

A slash introduces a step value: */5 in the minute field means "every fifth minute starting from 0", producing 0, 5, 10, 15, ..., 55. The step can be combined with an explicit range: 0-30/10 means 0, 10, 20, 30. A common mistake is reading */15 as "fifteen minutes after the hour starts" — it actually means "every fifteen minutes from minute 0", so it fires at :00, :15, :30, and :45.

Alphabetic Aliases

The month and day-of-week fields accept three-letter aliases case-insensitively: JAN through DEC, and SUN through SAT. 0 9 * * MON-FRI is identical to 0 9 * * 1-5. The aliases are useful when reading expressions cold — 0 0 1 JUL * is unambiguously "midnight on July 1st" — but they're not universally supported by every parser, so for portability the numeric form is the safer choice.

Patterns You'll Actually Use

The following table covers the patterns that recur in real production systems. Click any expression in the Cron Parser tool to see the next ten execution times in your local timezone.

Expression Meaning Typical Use
* * * * * Every minute Heartbeat probes, dev-only tasks
*/5 * * * * Every 5 minutes Frequent polling, log rotation checks
*/15 * * * * Every 15 minutes Cache refreshers, lightweight syncs
*/30 * * * * Every 30 minutes Mid-frequency reporting
0 * * * * Top of every hour Hourly digests, rate-limit resets
0 */2 * * * Every 2 hours on the hour Periodic batch jobs
0 */6 * * * Every 6 hours on the hour Quarter-day reports
0 0 * * * Daily at midnight Day-rollover jobs, log archival
0 2 * * * Daily at 2 AM Database backups (low-traffic window)
30 4 * * * Daily at 4:30 AM Off-peak maintenance
0 9 * * 1-5 Weekdays at 9 AM Standup reminders
0 9-17 * * 1-5 Weekdays 9 AM to 5 PM, hourly Business-hours sweeps
0 0 * * 0 Every Sunday at midnight Weekly batch jobs
0 0 1 * * Midnight on the 1st of each month Monthly billing snapshots
0 3 1 * * 3 AM on the 1st of each month Monthly reports
0 0 1 */3 * Quarterly (1st of every 3rd month) Quarterly reviews
0 0 1 1 * January 1st at midnight Annual rollovers, certificate renewals

For developer-facing schedules you'll see */15 * * * * most often — it strikes a balance between responsiveness and load. For operations, 0 2-4 * * * (sometime in the small hours) is the canonical "do unpleasant things while users sleep" window.

UNIX cron vs Quartz: The Big Confusion

The single most common bug we see in support threads is a Quartz expression pasted into a system that expects UNIX cron, or vice versa.

UNIX cron has five fields (no seconds) and supports * , - / plus alphabetic aliases. Linux crontab, macOS launchd StartCalendarInterval-equivalents, Kubernetes CronJob, Vercel Cron, and GitHub Actions all use this dialect.

Quartz has six or seven fields and adds:

  • Seconds field as the first column (0-59)
  • L — last day. In the day-of-month field, L means "last day of the month." In the day-of-week field, 6L means "the last Friday of the month."
  • W — nearest weekday. 15W means "the weekday closest to the 15th of the month."
  • # — nth weekday. 6#3 means "the third Friday of the month."
  • ? — used in either day-of-month or day-of-week to mean "no specific value," because Quartz disallows specifying both at once.

Spring Boot's @Scheduled(cron = "...") annotation uses Quartz's six-field format — 0 0 9 * * MON-FRI in Spring is 9 AM every weekday, not 9 AM every minute on Mondays. Pasting that string into a Linux crontab without removing the leading 0 is a recipe for jobs that never fire.

A useful tell: if you count exactly five whitespace-separated fields, it's UNIX cron. Six or seven, it's almost certainly Quartz or robfig/cron (Go).

Parser Libraries by Language

Most teams don't write their own cron parser — they use a library. Here are the ones most worth knowing.

JavaScript / Node.js

  • node-cron: lightweight, in-process cron scheduler. Five-field standard. The 3.x line added timezone support.
  • croner: actively maintained, TypeScript-first, supports seconds (six fields) optionally, and handles DST transitions explicitly.
  • cron: the long-standing library, used by many older codebases. Lightweight but DST behaviour requires care.

Python

  • croniter: not a scheduler — just a parser. Given an expression and a timestamp, it computes the next (or previous) fire time. Heavily used inside Airflow.
  • APScheduler: a full scheduler with cron, interval, and date triggers. Supports six-field expressions with seconds.

Go

  • robfig/cron (v3): the de facto Go scheduler. Defaults to a six-field format with seconds; use cron.New(cron.WithParser(cron.NewParser(cron.Minute|cron.Hour|cron.Dom|cron.Month|cron.Dow|cron.Descriptor))) to switch to the UNIX five-field format. Many bugs in Go services come from forgetting this.

Java

  • Quartz Scheduler: the canonical Java scheduler. Six or seven fields, full Quartz dialect (L, W, #, ?). Used standalone or via integrations.
  • Spring @Scheduled(cron = "..."): Quartz-style six-field format, but with Spring's own parser (some Quartz features like ? are not supported until Spring 5.3+).

C# / .NET

  • NCrontab: classic five-field UNIX cron parser for .NET. Lightweight.
  • Cronos: maintained by the Hangfire team, supports six-field expressions and handles DST and timezone conversions correctly. Recommended for production use where DST matters.

Ruby

  • whenever: a Ruby DSL that compiles to a crontab file. You write Ruby; it writes 0 9 * * MON for you.
  • rufus-scheduler: in-process scheduler, supports cron and natural-language schedules.

Platforms

  • Linux crontab: five-field standard, plus @reboot, @hourly, @daily, @weekly, @monthly, @yearly aliases.
  • Kubernetes CronJob (spec.schedule): five-field standard, UTC by default; Kubernetes 1.27+ supports spec.timeZone to override.
  • GitHub Actions (on.schedule.cron): five-field standard, always UTC, minimum interval five minutes, and may be delayed under load.
  • Vercel Cron: five-field standard, expressions checked at deploy time, UTC.

The Five Pitfalls Everyone Hits

1. Impossible Dates

0 0 30 2 * — "midnight on February 30th" — never fires. Cron silently skips impossible date combinations rather than raising an error, which is one of the most frustrating bugs to diagnose because nothing breaks; the job simply doesn't run. If you suspect a schedule has been silently skipped, paste it into the Cron Parser tool — when the next-execution preview is empty, you know the expression can never fire.

2. Day-of-Month and Day-of-Week OR-Logic

In standard Vixie cron, specifying both day-of-month and day-of-week produces a logical OR: 0 9 1 * 1 runs at 9 AM on the 1st of every month OR every Monday. This is rarely what people intend — most expect AND ("the 1st of the month, but only if it's also a Monday"). The fix is to leave one of the two fields as * and put the constraint in the application code, or use Quartz where ? disambiguates the intent.

3. DST and Timezone Surprises

UNIX cron expressions are interpreted in the system timezone. If a server is in America/New_York and you specify 0 2 * * *, the job will run twice on the day clocks fall back (once at 2 AM EDT, once at 2 AM EST) and not at all on the day clocks spring forward. Modern container schedulers — Kubernetes 1.27+, croner, Cronos — let you specify the timezone alongside the expression. For batch jobs the safer choice is UTC plus an awareness of when "midnight" actually means for the people reading the report.

4. The */N Reset Boundary

*/15 in the minute field fires at 0, 15, 30, 45 — relative to the start of the hour, not from the moment the job is registered. This means if you start the scheduler at 14:07, the first fire is at 14:15 (eight minutes later), not 14:22 (fifteen minutes later). For "every fifteen minutes starting now" semantics you need a different scheduler — cron isn't designed for relative intervals.

5. The "It Worked Yesterday" Trap with Quartz ?

If you copy a Quartz expression like 0 0 9 ? * MON-FRI (using ? in the day-of-month field to say "I don't care, the day-of-week field decides") into a Linux crontab, the ? is invalid and the expression fails — possibly silently, depending on the parser. The reverse is also true: many Quartz parsers require ? when both * and a specific day-of-week value would otherwise create ambiguity. The fix is always to know which dialect you're targeting before you copy.

Modern Alternatives Worth Knowing

Cron is not the only way to schedule jobs. A few alternatives that have eaten part of cron's role over the last decade:

  • systemd timers: built into modern Linux distributions, with a richer syntax (OnCalendar=Mon..Fri 09:00), DST awareness, and better logging via journalctl. The recommended choice for new Linux-only services.
  • launchd (macOS): not cron at all, but the macOS equivalent. Schedules are defined in property lists with StartCalendarInterval keys.
  • Temporal / DBOS / Inngest: durable workflow engines that include scheduling as a feature. Strictly more powerful than cron — they survive process restarts, support retries, and expose introspection — but the operational complexity is higher.
  • Cloud-native schedulers: AWS EventBridge Scheduler, Google Cloud Scheduler, Azure Logic Apps. These wrap cron-like syntax (mostly Quartz dialect) around managed infrastructure.

For most teams, plain cron — or Kubernetes CronJob in containerised environments — remains the right default. The alternatives are worth reaching for when retries, observability, or distributed coordination matter more than simplicity.

References

For interactive verification of any expression in this guide, use the Cron Parser tool — paste an expression and the next ten execution times are computed in your local timezone, with the human-readable description alongside.