The Short Answer
Standard cron is five fields separated by spaces. Each field maps to a position in time. The expression fires when all five match the current moment.
# ┌───────────── minute (0 - 59)
# │ ┌─────────── hour (0 - 23)
# │ │ ┌───────── day-of-month (1 - 31)
# │ │ │ ┌─────── month (1 - 12)
# │ │ │ │ ┌───── day-of-week (0 - 6, Sun=0)
# │ │ │ │ │
# * * * * * command_to_run
# Example: every weekday at 9:30am
30 9 * * 1-5- arrow_rightPosition 1: minute (0-59)
- arrow_rightPosition 2: hour (0-23)
- arrow_rightPosition 3: day-of-month (1-31)
- arrow_rightPosition 4: month (1-12 (or JAN-DEC))
- arrow_rightPosition 5: day-of-week (0-6 (Sun=0, or SUN-SAT))
Special Characters
Each field accepts five operator characters. Master these and you can express any schedule classical cron supports:
| Char | Meaning | Example |
|---|---|---|
| * | Any value | * * * * * — every minute |
| , | List of values | 0 9,12,15 * * * — at 9am, 12pm, 3pm |
| - | Range | 0 9-17 * * 1-5 — every hour 9-5 weekdays |
| / | Step | */15 * * * * — every 15 minutes |
| ? | No specific value (Quartz only) | 0 12 ? * MON — used to avoid OR conflict |
| L | Last (Quartz only) | 0 0 L * ? — midnight on last day of month |
The 12 Most Common Patterns
Most production cron expressions are some variation on these twelve. Bookmark this table and you will rarely need to think from scratch:
| Description | Expression | Next fire |
|---|---|---|
| Every minute | * * * * * | In 1 minute |
| Every 5 minutes | */5 * * * * | 12:00, 12:05, 12:10... |
| Every 15 minutes | */15 * * * * | 12:00, 12:15, 12:30, 12:45 |
| Every hour on the hour | 0 * * * * | 13:00, 14:00, 15:00... |
| Every 2 hours | 0 */2 * * * | 12:00, 14:00, 16:00... |
| Every day at midnight | 0 0 * * * | Tomorrow 00:00 |
| Every day at 9am | 0 9 * * * | Tomorrow 09:00 |
| Weekdays at 9am | 0 9 * * 1-5 | Next weekday 09:00 |
| Weekends at 10am | 0 10 * * 6,0 | Next Sat or Sun 10:00 |
| First day of every month | 0 0 1 * * | 1st of next month 00:00 |
| Every Monday at 8am | 0 8 * * 1 | Next Monday 08:00 |
| Quarterly (Jan/Apr/Jul/Oct 1st) | 0 0 1 */3 * | 1st of next quarter 00:00 |
Day-of-Month vs Day-of-Week — The OR Trap
The single most surprising behavior in cron: when both day-of-month (field 3) and day-of-week (field 5) are set to specific values, they are combined with OR, not AND.
# Naive read: "the 1st of the month AND a Monday"
# Actual: "the 1st of the month OR any Monday"
0 9 1 * 1
# This fires on every 1st of the month AT 9am
# AND every Monday at 9am — not just when both align!This is one of the oldest documented cron gotchas, dating back to the original V7 Unix implementation. POSIX standardized the OR behavior. If you actually want AND semantics — fire only when the 1st falls on a Monday — you cannot express it in standard cron. Quartz solves this with the ? character, which means "ignore this field"; you set whichever field you do not care about to ?.
The safe rule: never set both field 3 and field 5 to non-* values unless you actually want OR. Pick one or the other.
Time Zones
Standard cron uses the system timezone. On a server set to America/Los_Angeles, 0 9 * * * fires at 9am Pacific. Move that same crontab to a server in UTC and it now fires at 9am UTC — a 7 or 8 hour shift depending on DST.
Three problems compound: First, container images and most cloud servers default to UTC, so a developer who tested in their local TZ ships a job that fires at the wrong time. Second, daylight saving transitions create double-fires (2:30am gets repeated in fall) and skipped fires (2:30am does not exist in spring). Third, GitHub Actions and most cloud schedulers force UTC and refuse to read the system TZ.
The defensive pattern: always write cron expressions in UTC and document the local intent in a comment. 0 17 * * * # 9am Pacific (UTC-8) beats trying to make the scheduler timezone-aware.
Quartz Format
Quartz is the cron dialect used by Java schedulers — Spring's @Scheduled, Apache Airflow (in some operators), Quartz Scheduler itself. It extends standard cron with a seconds field and an optional year field, giving 6 or 7 fields total:
# Quartz 7-field format
# seconds minutes hours day-of-month month day-of-week year
0 0 9 ? * MON-FRI *
# Every weekday at 9:00:00 AM, every yearQuartz also adds three power operators absent from POSIX cron:
- arrow_rightL — last.
Lin day-of-month means last day;5Lin day-of-week means last Friday. - arrow_rightW — nearest weekday.
15Wmeans the weekday closest to the 15th. - arrow_right# — nth occurrence.
2#1in day-of-week means the first Monday of the month.
Pasting a Quartz expression into a POSIX crontab will produce a parse error. Pasting a POSIX expression into Quartz often silently runs at the wrong time because the leading field is interpreted as seconds, not minutes. Always verify the dialect first.
Platform Differences
The single biggest source of cron bugs in 2026 is assuming all platforms parse the same syntax. They do not. Here are the ones you will actually encounter:
| Platform | Fields | Seconds | Time zone | Notes |
|---|---|---|---|---|
| Linux crontab | 5 | No | System TZ | No L, W, or # special chars |
| GitHub Actions | 5 | No | UTC only | Min interval 5 min; jobs may delay during peak load |
| Kubernetes CronJob | 5 | No | UTC by default; spec.timeZone in v1.27+ | concurrencyPolicy: Allow / Forbid / Replace |
| AWS EventBridge | 6 | No | UTC | Year field added; ? required for one of day fields |
| Quartz (Spring) | 6 or 7 | Yes | JVM default | L, W, #, and ? supported |
Common Mistakes
Generating Cron Programmatically
In a UI that lets users pick a schedule, building cron expressions from typed options beats free-text input every time. Here is a small TypeScript builder that covers the cases users actually want — with type safety and a guard against the "step that does not divide evenly" mistake:
// Type-safe cron generator for common cases
type CronOptions =
| { type: 'every'; minutes: number }
| { type: 'hourly'; minute?: number }
| { type: 'daily'; hour: number; minute?: number }
| { type: 'weekly'; day: 0|1|2|3|4|5|6; hour: number; minute?: number }
| { type: 'monthly'; day: number; hour: number; minute?: number };
function buildCron(opts: CronOptions): string {
switch (opts.type) {
case 'every':
if (60 % opts.minutes !== 0) {
throw new Error('minutes must divide evenly into 60');
}
return `*/${opts.minutes} * * * *`;
case 'hourly':
return `${opts.minute ?? 0} * * * *`;
case 'daily':
return `${opts.minute ?? 0} ${opts.hour} * * *`;
case 'weekly':
return `${opts.minute ?? 0} ${opts.hour} * * ${opts.day}`;
case 'monthly':
return `${opts.minute ?? 0} ${opts.hour} ${opts.day} * *`;
}
}
buildCron({ type: 'every', minutes: 15 }); // "*/15 * * * *"
buildCron({ type: 'daily', hour: 9, minute: 30 }); // "30 9 * * *"
buildCron({ type: 'weekly', day: 1, hour: 8 }); // "0 8 * * 1"Wrapping cron in a typed builder eliminates four classes of bugs at the type-system level: invalid day-of-week values, missing minute fields, illegal step intervals, and the OR-trap (because the user never sets both day fields). For the cases this builder cannot express — multi-time daily, complex skip patterns — fall back to free text but validate it with a parser like cron-parser before saving.
Build cron expressions visually
Pick a schedule, see the expression and the next 10 fire times — copy-ready for any platform.