Back to blog

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 Sunday

Step 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:00

Day-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 specifically

Ten 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-Fri

Named 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 startup

Quartz: 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 2026

AWS 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 creation

Timezones, 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>&1

When 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.

Try these tools