Posted · 8 min read
Cron Expressions From Scratch: A Visual Guide
Learn the 5-field POSIX cron syntax, special characters, step values, Quartz extensions, AWS cron differences, and a dozen worked examples you can copy and paste into a crontab today.
Why cron is still everywhere
Cron was written by Brian Kernighan for Version 7 Unix in 1979. The version most modern Linux systems still ship — Vixie cron, written by Paul Vixie in 1987 — added environment variables, per-user crontabs and the friendly @reboot shortcut, but it kept the original five-field schedule format almost untouched. That stability is the whole reason a syntax designed before the World Wide Web existed is still pasted into Kubernetes manifests, GitHub Actions workflows, AWS EventBridge rules and Spring @Scheduled annotations in 2026.
If you only learn cron by copying lines from Stack Overflow, the schedule eventually does something you did not expect — your daily backup runs every minute, your weekend job fires on Tuesdays, your timezone shifts on a DST boundary. This guide walks through the syntax from the ground up, then shows enough worked examples that you can recognise the shape of any expression at a glance.
The 5-field format
A POSIX crontab line is a schedule followed by a command. The schedule is exactly five whitespace-separated fields: minute, hour, day of month, month, day of week. Every field accepts a number, a list, a range, a step, or the wildcard *.
* * * * * command-to-run
┬ ┬ ┬ ┬ ┬
│ │ │ │ │
│ │ │ │ └─── day of week (0-6, Sunday = 0; 7 also = Sunday on most cron implementations)
│ │ │ └───── month (1-12, or JAN-DEC)
│ │ └─────── day of month (1-31)
│ └───────── hour (0-23)
└─────────── minute (0-59)Special characters: * , - / and friends
Five characters do almost all the work in classic cron. Learn what each one means and most expressions read like a sentence.
- * — every value in this field. * in the minute field means "every minute".
- , — list of discrete values. 0,15,30,45 in the minute field means four fixed minutes per hour.
- - — inclusive range. 1-5 in the day-of-week field means Monday through Friday.
- / — step value, written as range/step or */step. */10 in minute means "every 10 minutes starting at 0".
- L, W, # — Quartz extensions, not part of POSIX. L = last, W = nearest weekday, # = nth weekday of month. More on these below.
0,30 * * * * at :00 and :30 every hour
*/5 * * * * every 5 minutes (0, 5, 10, 15 ...)
0 9-17 * * 1-5 every hour from 09:00 to 17:00, Mon-Fri
0 0 1,15 * * midnight on the 1st and 15th of every month
15 14 * * 0 14:15 every SundayStep values are not what you think
The step operator / always works against a range, even when the range is implicit. */15 in the minute field is shorthand for 0-59/15, which fires at 0, 15, 30 and 45. That is why */7 in minute does NOT mean "every 7 minutes from now" — it means "the multiples of 7 between 0 and 59", which gives you 0, 7, 14, 21, 28, 35, 42, 49, 56 and then a 4-minute gap before midnight rolls over. If you need a true rolling interval, use a job runner or systemd timer with OnUnitActiveSec.
Steps can also start partway through a range. 5-59/10 in minute fires at 5, 15, 25, 35, 45, 55. Combine ranges and steps when you want a non-symmetric schedule without writing a giant comma list.
*/15 * * * * 00, 15, 30, 45 (good)
*/7 * * * * 00, 07, 14, 21, 28, 35, 42, 49, 56, then a 4-min gap (probably not what you want)
5-59/10 * * * * 05, 15, 25, 35, 45, 55
0 */4 * * * 00:00, 04:00, 08:00, 12:00, 16:00, 20:00Day-of-month and day-of-week: the OR rule
The single biggest cron foot-gun: when both day-of-month and day-of-week are restricted (neither is *), Vixie cron treats them as a logical OR, not AND. The line 0 0 13 * 5 fires at midnight on the 13th of every month AND every Friday — not just Friday the 13th. To get the AND behaviour, restrict only one of the two fields and filter the other one in your script, or use a scheduler like Quartz that supports the ? placeholder for "no specific value".
0 0 13 * 5 midnight on the 13th OR any Friday (Vixie cron OR rule)
0 0 * * 5 every Friday at midnight
0 0 13 * * every 13th of the month at midnight
# Quartz: 0 0 0 13 * 5 ? would match Friday the 13th specificallyTen copy-paste examples
Print this table out and stick it next to your monitor. Most production cron lines you will ever write are minor variations on these.
* * * * * every minute
0 * * * * every hour at :00
*/15 * * * * every 15 minutes
0 0 * * * every day at midnight (server local time)
0 9 * * 1-5 weekdays at 09:00
30 14 * * 1-5 weekdays at 14:30
0 9 * * 1 every Monday at 09:00
0 22 * * 0 every Sunday at 22:00
0 0 1 * * midnight on the 1st of every month
0 0 1 1 * midnight on January 1st
0 3 * * 6 every Saturday at 03:00 (typical backup window)
*/5 9-17 * * 1-5 every 5 minutes during business hours, Mon-FriNamed shortcuts
Vixie cron, GNU mcron and most descendants accept a handful of @-prefixed aliases that map to common schedules. They are easier to read than the equivalent five fields and harder to typo.
@yearly same as 0 0 1 1 * (also @annually)
@monthly same as 0 0 1 * *
@weekly same as 0 0 * * 0
@daily same as 0 0 * * * (also @midnight)
@hourly same as 0 * * * *
@reboot run once at system startupQuartz: the 6 and 7 field cousin
Java schedulers — Quartz itself, Spring's @Scheduled with cron =, and a handful of cloud platforms — use a different dialect. Quartz prepends a seconds field and optionally appends a year field, giving you six or seven fields total. It also supports L (last), W (nearest weekday), # (nth weekday of month) and the ? placeholder that means "no specific value, only one of day-of-month and day-of-week is set".
If you copy a Quartz expression into a Linux crontab it will silently misparse, because the leading 0 looks like a normal minute value to Vixie cron. Always know which dialect your scheduler expects before you save the file.
Quartz fields: second minute hour day-of-month month day-of-week [year]
0 0 12 * * ? every day at noon
0 15 10 ? * MON-FRI weekdays at 10:15
0 0 0 L * ? last day of every month at midnight
0 0 0 LW * ? last weekday of every month
0 0 9 ? * 2#1 first Monday of every month at 09:00
0 0 9 ? * 6#3 third Friday of every month at 09:00
0 0 0 1 * ? 2026 midnight on the 1st of every month, but only in 2026AWS rate vs cron expressions
EventBridge, CloudWatch Events and Lambda use a near-Quartz cron syntax with two important deviations. There is no seconds field (six fields, not seven) and you must use ? in either day-of-month or day-of-week — never both as *. AWS also offers rate(value unit) for simple intervals, which avoids the OR foot-gun entirely.
AWS cron fields: minute hour day-of-month month day-of-week year
cron(0 12 * * ? *) every day at 12:00 UTC
cron(0/15 * * * ? *) every 15 minutes
cron(0 9 ? * MON-FRI *) weekdays at 09:00 UTC
cron(0 0 1 * ? *) midnight UTC on the 1st of every month
rate(5 minutes) every 5 minutes
rate(1 hour) every hour
rate(7 days) every 7 days starting from rule creationTimezones, DST and the things that break at 02:30
Vixie cron honours the system timezone (TZ environment variable, or whatever /etc/localtime points at). When daylight saving time skips an hour forward, any job scheduled inside the gap is silently skipped. When DST falls back, jobs scheduled inside the duplicated hour run twice on some implementations and once on others. The two safe defaults: run cron in UTC, or schedule outside the 01:00-04:00 window.
Cloud schedulers behave differently. EventBridge always runs in UTC. Kubernetes CronJobs honour the spec.timeZone field added in v1.27. Quartz uses the JVM default unless you pass a CronTrigger with a timezone. Always read the docs once for whichever scheduler you are configuring; do not assume the convention from the last one carries over.
Debugging an expression that does not fire
Nine times out of ten the expression itself is fine and the environment is wrong. Walk this checklist before you blame the schedule.
- Read the cron daemon log: grep CRON /var/log/syslog on Debian/Ubuntu, journalctl -u cron on systemd hosts. The log shows the resolved command line cron actually executed.
- Confirm the user. crontab -l with no flag shows YOUR crontab; the schedule you care about may be in /etc/crontab, /etc/cron.d/*, or another user's crontab.
- Check PATH. Cron runs with a minimal PATH (usually /usr/bin:/bin). Use absolute paths or set PATH= at the top of the crontab.
- Check the shell. Cron uses /bin/sh, not your login shell. Anything bash-specific (process substitution, [[ )) needs an explicit bash -c wrapper.
- Redirect output. Without > /var/log/myjob.log 2>&1 a failing job emails root locally and you never see it.
- Confirm the timezone. date && date -u inside the job logs both. If they disagree with what your schedule assumed, that is your bug.
# A safer cron line:
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
MAILTO=""
*/5 * * * * /usr/local/bin/sync.sh >> /var/log/sync.log 2>&1When cron is the wrong tool
Cron is excellent for fixed wall-clock schedules and unbeatable for one-line jobs on a single host. It is a poor fit when you need: distributed coordination across many machines (use a queue, a leader-elected scheduler, or Kubernetes CronJobs with concurrencyPolicy: Forbid); retries and back-off on failure (wrap the script or use a workflow engine); sub-minute granularity (use a systemd timer or an event-driven trigger); or schedules that depend on the previous run finishing first (cron will happily start a second copy while the first is still running). Recognising the limit early saves a lot of 3 a.m. pages.
Build and verify visually
If you write more than a couple of cron lines a month, the highest-leverage habit is checking each expression against a parser before you commit it. Multilities ships a free /tools/cron-builder that decodes a 5- or 6-field expression into plain English and shows the next ten fire times in your local timezone, which is exactly the cross-check that catches the day-of-month/day-of-week OR trap and the */7-style step surprise. Pair that with a quick unit test that asserts the next-fire time for a known anchor date and your scheduled jobs become boringly reliable — which is what you want them to be.