Cron Expressions Explained: The Complete Guide to Scheduling Jobs Like a Pro
If you have ever stared at a string like */15 * * * * and wondered what on earth it means, you are not alone. Cron expressions are one of those things every developer, DevOps engineer and system administrator eventually has to learn — and almost everyone learns them the hard way, usually at 2 a.m. when a job fires at the wrong time. This guide explains cron expressions from the ground up: what each field does, how the special characters work, where the classic traps hide, and how the Quartz and GitHub Actions dialects differ from standard cron.
What is a cron expression?
A cron expression is a compact text string that describes a recurring schedule. The name comes from cron, the time-based job scheduler built into Unix-like operating systems since the 1970s. You write the expression, the scheduler reads it, and your command, script or workflow runs whenever the current time matches the pattern.
Today cron syntax lives far beyond Linux servers. You will find it in Jenkins build triggers, Kubernetes CronJobs, the Java Quartz scheduler, cloud platforms like AWS EventBridge, and CI systems like GitHub Actions. The core idea is identical everywhere: five (sometimes six or seven) fields, each describing one unit of time, read left to right from the smallest unit to the largest.
The reason cron expressions confuse people is density. Five numbers and asterisks carry a surprising amount of meaning, and two expressions that look almost identical — 0 9 * * * versus 0 9 * * 1 — behave completely differently. A cron parser that translates the expression into plain English, like the console above, is the fastest way to check that what you wrote is what you meant.
The five fields, decoded
A standard cron expression has five fields separated by spaces, always in this order:
| Position | Field | Allowed values | Special characters |
|---|---|---|---|
| 1 | Minute | 0–59 | * , - / |
| 2 | Hour | 0–23 | * , - / |
| 3 | Day of month | 1–31 | * , - / |
| 4 | Month | 1–12 or JAN–DEC | * , - / |
| 5 | Day of week | 0–7 or SUN–SAT | * , - / |
So 30 2 * * * reads as: minute 30, hour 2, every day of the month, every month, every day of the week — in plain English, "at 02:30 every day". And 0 9 * * 1-5 means "at 09:00, Monday through Friday". The fields are always evaluated together: a job fires only when all the field conditions are satisfied at the same time — with one famous exception involving the two day fields, which we will get to shortly.
Note that hours use the 24-hour clock. 0 14 * * * is 2 p.m., not 2 a.m. There is no AM/PM marker in cron, which is another classic source of midnight-versus-noon confusion: 0 0 * * * is midnight, 0 12 * * * is noon.
Special characters: the *, ,, - and / operators
Four special characters do all the heavy lifting in cron expressions. Master these and you can read any schedule:
Asterisk (*) — "every". A star means "every possible value for this field". * * * * * therefore means "every minute of every hour of every day" — the most frequent schedule cron can express. In the minute field, 0 * * * * means "at minute zero of every hour", i.e. once per hour.
Comma (,) — lists. Separate individual values: 0 9,18 * * * runs at 09:00 and 18:00 every day. 0 0 * * 1,3,5 runs at midnight on Monday, Wednesday and Friday.
Hyphen (-) — ranges. 0 9 * * 1-5 covers Monday through Friday. 0 0 * 1-3 * runs daily during the first quarter of the year. Ranges are inclusive on both ends.
Slash (/) — steps. The step operator picks every nth value: */15 * * * * means "every 15 minutes" (minutes 0, 15, 30, 45). 0 */2 * * * means "every 2 hours". You can combine steps with ranges too: 0 9-17/2 * * * fires at 09:00, 11:00, 13:00, 15:00 and 17:00 — every two hours during the working day.
One subtle point: */10 in the minute field starts counting from 0, giving minutes 0, 10, 20, 30, 40, 50. If you want "10 minutes past every hour" instead, write the literal 10 * * * *. The step always anchors at the field's minimum unless you give it an explicit range like 5-55/10.
Names instead of numbers
In the month and day-of-week fields you can use three-letter English names instead of numbers: JAN, FEB, MAR … DEC and SUN, MON, TUE, WED, THU, FRI, SAT. Names are case-insensitive, so 0 9 * * mon-fri works exactly like 0 9 * * 1-5. They work inside ranges and lists as well: 0 0 1 JAN,APR,JUL,OCT * runs at midnight on the first day of each quarter.
A small quirk worth knowing: in standard cron, both 0 and 7 mean Sunday in the day-of-week field. So 0 0 * * 0 and 0 0 * * 7 are identical. (Quartz does this differently — more on that below.)
The day-of-month vs day-of-week trap: the OR rule
Here is the single most misunderstood behavior in all of cron. When both the day-of-month and day-of-week fields are restricted (neither is *), the job runs when either condition matches — not when both match. This is the OR rule, and it surprises almost everyone the first time.
Consider 0 0 1 * 1. A newcomer reads it as "midnight on the 1st of the month, but only if it's a Monday". What it actually means is "midnight on the 1st of every month, or every Monday" — roughly five runs a month instead of the occasional one. If you truly want "the first Monday of the month", standard cron cannot express it directly; you need a wrapper script that checks the date, or a scheduler with richer syntax.
The rule, precisely: if day-of-month is *, only day-of-week is considered; if day-of-week is *, only day-of-month is considered; if both are restricted, the job fires when either matches; if both are *, it fires every day. Paste any suspicious expression into the parser above and check the field breakdown — it spells out exactly which rule is in play.
Quartz cron: the 6- and 7-field dialects
The Quartz scheduler, popular in Java applications, extends cron with extra fields and slightly different conventions. A Quartz expression has six fields — seconds, minutes, hours, day-of-month, month, day-of-week — with an optional seventh for the year. So the Quartz equivalent of "every 15 minutes" is 0 */15 * ? * *: note the leading seconds field and the ?.
The question mark is Quartz's "no specific value" placeholder, used in exactly one of the two day fields. Because Quartz evaluates day-of-month and day-of-week with AND logic instead of cron's OR rule, it forces you to disambiguate: put ? in the day field you don't care about. 0 0 12 ? * WED means "12:00 every Wednesday"; 0 0 12 15 * ? means "12:00 on the 15th of every month". Setting both day fields to specific values is an error in Quartz, and the parser above flags it for you.
Another Quartz difference: day-of-week is numbered 1 (Sunday) through 7 (Saturday), unlike standard cron's 0–6. Quartz also supports advanced characters like L (last), W (nearest weekday) and # (nth weekday of the month, as in 3#2 for the second Tuesday). Those are powerful but rare; most teams never need them, and this parser intentionally asks you to expand them into explicit values so the plain-English explanation stays exact.
GitHub Actions schedules
GitHub Actions' schedule trigger uses standard 5-field POSIX cron syntax — the same grammar as a Linux crontab. Two practical differences matter. First, the minimum interval is five minutes; anything more frequent, like * * * * *, will not run every minute. Second, scheduled workflows run in UTC and can be delayed by several minutes during periods of high load, so never design a workflow that depends on to-the-second timing. The parser above includes a GitHub Actions mode that warns you automatically if your expression fires more often than every five minutes.
Daylight saving time: the skip and the double
Cron schedules are evaluated in wall-clock time, which means daylight saving transitions can do strange things to your jobs twice a year. In spring, when clocks jump forward (for example 02:00 → 03:00), any schedule targeting the skipped hour simply never fires that day. A backup job set for 30 2 * * * silently misses its run on the spring-forward Sunday — no error, no retry, just a missing backup you might not notice for weeks.
In autumn the opposite happens: when clocks fall back, the repeated hour occurs twice, and a job scheduled inside it can fire twice. Whether it actually double-fires depends on your scheduler — some dedupe, some don't — but the risk is real, especially for jobs that send emails, charge customers or mutate data.
The practical advice: avoid scheduling critical jobs between 01:00 and 03:00 local time, make jobs idempotent so a double run is harmless, and always verify DST behavior in the timezone your scheduler actually uses (often UTC on servers, which has no DST at all). The console above computes every run in your selected timezone and flags both skips and potential double-fires automatically — one of the few free tools that does.
Cron cheat sheet: common examples
| Expression | Meaning |
|---|---|
| * * * * * | Every minute |
| */15 * * * * | Every 15 minutes |
| 0 * * * * | Every hour, on the hour |
| 0 9 * * * | Every day at 09:00 |
| 0 9 * * 1-5 | Weekdays at 09:00 |
| 30 2 * * 0 | Sundays at 02:30 |
| 0 0 1 * * | First day of every month at midnight |
| 0 0 1 1 * | Once a year: midnight on January 1st |
| 0 12 * * 6 | Saturdays at noon |
| 0 0 * * 0 | Sundays at midnight |
Mistakes to avoid
Swapping minute and hour. 0 9 * * * (9 a.m. daily) versus 9 0 * * * (9 minutes past midnight) — one transposed field, wildly different behavior. Always double-check field order with a parser before deploying.
Assuming the server's timezone. A crontab entry runs in the system's local timezone, which on many servers is UTC — not your laptop's timezone. A job you think runs at "9 a.m." might run at 9 a.m. UTC. When in doubt, set the timezone explicitly and verify with the next-runs list.
Overlapping runs. If a job takes 20 minutes but its schedule fires every 10, instances pile up and can exhaust memory, duplicate work or corrupt data. Use the overlap-risk check above: enter your expected job duration and it compares it against the real interval between consecutive runs.
Impossible dates. 0 0 30 2 * looks valid but February never has 30 days — the job will never run, and cron will never tell you. Similarly 0 0 29 2 * only fires in leap years. A good tester reports "no upcoming runs" instead of leaving you guessing.
Testing in production. Never deploy an untested schedule for anything important. Paste the expression into a tester, read the plain-English explanation out loud, scan the next ten run times, and run the would-it-fire diagnostic against a few specific dates — including a DST transition weekend. Thirty seconds of checking beats a 2 a.m. incident every time.