Uttir
By Uttir 5 min read

Cron Expressions Explained: From "Every 5 Minutes" to Complex Schedules

A complete guide to cron expressions: what each field means, common patterns, special characters (* ? / , -), day-of-week gotchas, and how to test your cron before shipping it.

A cron expression is five (or six) space-separated fields — minute, hour, day-of-month, month, day-of-week, and optionally second — that define a recurring schedule. Use a cron parser like Uttir to preview the next 10 fire times before you ship, because day-of-week numbering (0 vs. 7 for Sunday) and timezone interpretation bite everyone eventually.

Cron is the universal language of "do this thing on a schedule." Every Linux box, every CI system, every serverless scheduler, and most SaaS products speak some dialect of it. The format looks like line noise the first time you see it, but once you understand the five fields, you can read and write cron expressions without reaching for a cheat sheet.

The basic format

A standard cron expression has five fields, separated by spaces:

* * * * *
│ │ │ │ │
│ │ │ │ └─── day of week (0–7, Sunday = 0 or 7)
│ │ │ └───── month (1–12)
│ │ └─────── day of month (1–31)
│ └───────── hour (0–23)
└─────────── minute (0–59)

Read it left to right: "at minute X past hour Y on day Z of month W, during month M, on day-of-week D". A field of * means "every possible value" — so * * * * * means "every minute of every hour of every day of every month on every day of the week", which is to say, every minute, full stop.

Some implementations add a sixth field at the start for seconds (Quartz scheduler, some cloud offerings), and a seventh for year. Standard POSIX cron is five fields; if you see six or seven, check the docs.

The special characters

Five characters do the heavy lifting in cron expressions:

* — every

"Every value". * in the hour field means "every hour". Combined with the next field, it composes: 0 * * * * means "at minute 0 of every hour" (i.e., on the hour, every hour).

, — list

Comma-separated values. 0,30 * * * * means "at minute 0 and minute 30 of every hour" (i.e., on the hour and half-hour).

- — range

Inclusive range. 0 9-17 * * * means "at minute 0 of hours 9 through 17" (i.e., on the hour, from 9 AM to 5 PM).

/ — step

"Every N". */5 * * * * means "every 5 minutes" (minutes 0, 5, 10, 15, ...). 0 */2 * * * means "every 2 hours" (hours 0, 2, 4, 6, ...). */15 9-17 * * 1-5 means "every 15 minutes during business hours on weekdays".

? — no specific value (some implementations)

Used in Quartz and AWS CloudWatch instead of * for day-of-month or day-of-week, because the spec requires exactly one of those two to be specified. ? means "I don't care, use the other one".

The gotcha: day-of-month AND day-of-week

Here's the rule that breaks everyone at least once: when both the day-of-month and day-of-week fields are non-*, cron fires when either matches, not both. So 0 9 * * 1 fires at 9 AM every Monday, and 0 9 1 * * fires at 9 AM on the first of every month, but 0 9 1 * 1 fires at 9 AM whenever it's the first of the month or a Monday. The intersection (first Monday) is not implied.

If you want "the first Monday of the month", you can't express it in pure cron. You have to use a wrapper script, a Quartz-style 0 0 9 ? * 2#1 ("second day-of-week, week 1"), or trigger cron daily and let the script decide.

Common patterns, decoded

ExpressionMeaning
0 0 * * *Daily at midnight
0 9 * * 1-5Weekdays at 9 AM
*/15 * * * *Every 15 minutes
0 0 1 * *First of the month at midnight
0 0 * * 0Weekly on Sunday at midnight
0 0 1 1 *Yearly on January 1st at midnight
30 14 * * 5Fridays at 2:30 PM
0 9-17/2 * * *Every 2 hours between 9 AM and 5 PM
0 0,12 * * *Twice a day: midnight and noon
0 22 * * 1-5Weeknights at 10 PM (a backup window)

Day-of-week: 0, 7, and SUN

Sunday is both 0 and 7. 0 is the historical POSIX choice; 7 is allowed in some implementations so the range 1-7 covers the full week. SUN, MON, etc. are accepted by most modern parsers.

Quirks to know:

  • crontab(5) on Linux accepts both 0 and 7 for Sunday.
  • Quartz uses 1–7 with 1 = Sunday.
  • Spring's @Scheduled follows Quartz.
  • GitHub Actions cron follows POSIX.
  • CloudWatch Events uses 1–7 with 1 = Sunday and supports ?.

Before you copy a cron expression from Stack Overflow, double-check the target system's day-of-week convention.

Timezones: the silent bug

Most cron implementations assume the server's local timezone. That's a problem when your server is in UTC and your business is in Singapore, or when daylight saving time starts and your "9 AM" job fires at 8 AM (or 10 AM, depending on the direction of the change). A few patterns to keep in mind:

  • Use TZ=America/New_York at the top of the crontab to pin the timezone. Most cron daemons respect it.
  • Prefer UTC for the schedule and convert in your head, or store the user's timezone in your database and have the job query for "who is in their 9 AM right now".
  • Spring Forward / Fall Back days are the usual suspects for "the job didn't fire" / "the job fired twice" complaints.

How to test a cron expression

You should always test a cron expression before shipping it to production. The two-question test:

  1. What are the next 10 fire times? (If any of them look wrong, fix the expression.)
  2. What was the last fire time? (If you're shipping a job that should have run last Tuesday and didn't, find out before you deploy.)

Uttir's cron parser answers both questions in your browser. Paste the expression, get the next 10 fire times formatted in your local timezone, and a "previous fire" timestamp. If you're authoring from scratch, the cron generator lets you click "every 5 minutes" / "weekdays at 9 AM" / "first of the month" presets and see the resulting expression — useful for the times when you know what you want in English but not in cron syntax.

Quirks and footguns

A short list of things that have, at some point, woken someone up at 3 AM:

  • Daylight saving time — a "2 AM" job doesn't fire on Spring Forward; a "1 AM" job fires twice on Fall Back.
  • Leap seconds — cron implementations vary. Most just skip the leap second; a few fire the job an extra time.
  • Year field — POSIX cron has no year field, so 0 0 29 2 * means "every February 29th", which only fires on leap years. If you mean "Feb 29 of every leap year", that's the right expression. If you mean "Feb 29 every year", that doesn't exist.
  • Day-of-month 31 — runs only in months with 31 days. 0 0 31 * * fires seven times a year, not twelve.
  • Environment variables — cron gives you a near-empty environment. PATH is usually /usr/bin:/bin. If your script needs ~/.local/bin, set it in the crontab.

Wrap-up

Cron has been around since 1975, and its grammar is small enough to learn in a weekend: five fields, five special characters, and a handful of gotchas around day-of-month/day-of-week, timezones, and DST. The fastest way to internalize it is to read cron expressions out loud. 0 9 * * 1-5 is "at 9:00 AM, Monday through Friday" — say it like that and the syntax clicks.

Bookmark a cron parser, test every expression before you ship it, and never deploy a cron change on a Friday afternoon.

#cron#developer-tools#scheduling#devops