Cron is the oldest, dumbest, and most reliable scheduler most of us will ever use. The syntax fits on one line, which is exactly why people get it wrong — a single misplaced asterisk can turn a nightly backup into a job that fires every minute. This guide walks through the five fields, the special characters, the schedules you'll actually write, and the timezone traps that quietly cause incidents.

The five fields

A classic cron expression is five space-separated fields, read left to right:

text
┌───────────── minute (0–59) │ ┌─────────── hour (0–23) │ │ ┌───────── day of month (1–31) │ │ │ ┌─────── month (1–12 or JAN–DEC) │ │ │ │ ┌───── day of week (0–6 or SUN–SAT, 0 = Sunday) │ │ │ │ │ * * * * * command to run

All five asterisks (

text
* * * * *
) means "every minute of every hour of every day" — the most frequent schedule classic cron can express. There is no seconds field in standard Unix cron. Some systems (Quartz, Spring's
text
@Scheduled
, many cloud schedulers) add a sixth field at the front for seconds, so always confirm which dialect your platform speaks before copying an expression from somewhere else.

The field that surprises people most is day-of-week. It overlaps with day-of-month, and the two interact in a way that breaks intuition (more on that below).

Special characters

Four characters do almost all the work.

Asterisk
text
*
— every value

text
*
means "every valid value for this field." In the minute field it's every minute; in the month field, every month. It's the default "don't care" marker.

Slash
text
/
— steps

A step runs at fixed intervals across a range.

text
*/5
in the minute field means "every 5 minutes": 0, 5, 10, 15, and so on. You can anchor the step to a range, so
text
0-30/10
means minutes 0, 10, 20, 30 only.

A common mistake:

text
*/90
does not mean "every 90 minutes." Each field is independent and capped at its own maximum (59 for minutes), so
text
*/90
collapses to just minute 0. To run something every 90 minutes you have to enumerate the actual minutes-and-hours, which cron can't express cleanly — that's a real limit, not a bug in your expression.

Comma
text
,
— lists

Commas join specific values.

text
0 9,12,17 * * *
runs at 09:00, 12:00, and 17:00. Lists and steps combine, so
text
0 0,12 * * *
is noon and midnight.

Hyphen
text
-
— ranges

A hyphen is an inclusive range.

text
0 9-17 * * *
runs every hour on the hour from 9 AM through 5 PM (nine runs). For weekdays,
text
1-5
in the day-of-week field covers Monday through Friday.

You can mix all of these in one field:

text
0 9-17/2 * * 1-5
runs every two hours from 9 to 5, Monday to Friday.

Common schedules you'll actually write

Here are the expressions worth memorizing.

text
*/5 * * * * Every 5 minutes 0 * * * * Every hour, on the hour 0 0 * * * Every day at midnight (nightly) 30 2 * * * Every day at 2:30 AM 0 9 * * 1-5 9:00 AM, weekdays only 0 0 * * 0 Every Sunday at midnight 0 0 1 * * Midnight on the 1st of every month 0 0 1 1 * Midnight on January 1st (yearly) 15 14 1 * * 2:15 PM on the 1st of every month 0 22 * * 1-5 10:00 PM on weekdays

A few notes on these:

  • Nightly jobs cluster at midnight. If every cron job on a box fires at
    text
    0 0 * * *
    , they all compete for CPU and I/O at once. Stagger them —
    text
    5 0
    ,
    text
    17 0
    ,
    text
    40 0
    — so they don't stampede.
  • 2:30 AM is a popular maintenance window because traffic is low, but see the DST warning below before you trust early-morning times.
  • Weekday business hours (
    text
    0 9 * * 1-5
    ) is the canonical "send the daily report" schedule.

Many schedulers also accept named shortcuts:

text
@hourly
,
text
@daily
(=
text
@midnight
),
text
@weekly
,
text
@monthly
,
text
@yearly
, and
text
@reboot
(run once at startup). These are easier to read than
text
0 0 * * *
, but they're not universal — some minimal cron builds and most cloud schedulers don't support them.

The day-of-month / day-of-week trap

This one bites everyone eventually. When both the day-of-month and day-of-week fields are restricted (neither is

text
*
), standard Unix cron treats them as OR, not AND.

So this expression:

text
0 0 13 * 5

does not mean "midnight on Friday the 13th." It means "midnight on the 13th of the month OR any Friday" — which is most of the time. To get an actual AND, you generally need a logic check in the command itself, or a scheduler dialect (like Quartz) that handles it differently. If your expression restricts both day fields and the behavior looks wrong, this is almost always why.

The safe habit: leave one of the two day fields as

text
*
whenever you can.

Timezone gotchas

Timezones are where cron quietly causes production incidents.

Cron uses the machine's local time

Classic cron evaluates expressions against the system clock and its configured timezone, not UTC. The same

text
0 9 * * *
runs at a different absolute moment on a server in
text
Asia/Kolkata
than one in
text
America/New_York
. If you migrate a box or spin up infrastructure in a new region, your "9 AM" job silently moves.

The defensive move: standardize servers on UTC and do the mental conversion once, or set the timezone explicitly per job where your cron supports it (many modern crons accept a

text
CRON_TZ=
prefix line, and most cloud schedulers take an explicit timezone parameter).

text
# Some cron implementations support this: CRON_TZ=America/New_York 0 9 * * 1-5 /usr/local/bin/daily-report

Daylight Saving Time creates missing and doubled runs

If your job runs during the DST transition window, two things can happen:

  • Spring forward: the clock jumps from 1:59 to 3:00. A job scheduled for
    text
    30 2 * * *
    lands in a time that never existed that day. Depending on the implementation, it may be skipped entirely.
  • Fall back: the clock repeats the 1–2 AM hour. A 2:30 job can run twice.

This is exactly why so many teams schedule maintenance at 2:30 AM and then discover their backup either vanished or duplicated twice a year. The fix is to schedule jobs outside the 1–3 AM local window, or run on UTC where there is no DST shift at all. If a job must be idempotent (safe to run twice), make it so — don't rely on cron firing exactly once across a DST boundary.

How to verify next runs before you trust an expression

Never deploy a cron expression you haven't sanity-checked. Reading the next several fire times out loud catches the day-of-week OR-trap, the

text
*/90
collapse, and off-by-one ranges immediately.

Read it back in plain English. Force yourself to translate each field. "Minute 0, hours 9 through 17, every day, every month, weekdays" should match your intent word for word. Our cron expression builder does this translation live and lists the upcoming run times as you edit, so you can confirm the schedule before it ever reaches a server. It runs entirely in your browser — nothing about your schedules is uploaded.

Dry-run with the system parser when you can. On systems with

text
systemd
, timers expose their own schedule check:

text
systemd-analyze calendar "Mon..Fri 09:00"

That prints the next elapse time using the exact logic the scheduler will use — the most trustworthy verification, because it's the same code path that will run the job.

Test the command separately from the schedule. A correct expression running a broken command still fails silently. Run the command by hand first, with the same user and environment cron will use. Cron jobs run with a minimal

text
PATH
and almost no environment, so a script that works in your shell often dies under cron because it can't find a binary or an env var. Use absolute paths and source whatever environment you need at the top of the script.

Log and alert. Append output to a file and check it after the first scheduled run:

text
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

The

text
2>&1
captures stderr too — without it, errors disappear. For anything that matters, add a dead-man's-switch (a heartbeat ping that alerts you when a job doesn't run), because the worst cron failure is the one nobody notices for a month.

Quick reference

  • Five fields: minute, hour, day-of-month, month, day-of-week.
  • text
    *
    = every,
    text
    /
    = step,
    text
    ,
    = list,
    text
    -
    = range.
  • Steps don't wrap fields —
    text
    */90
    minutes won't give you 90-minute intervals.
  • Restricting both day fields means OR, not AND.
  • Cron uses local time; standardize on UTC and avoid the 1–3 AM DST window.
  • Always read the next runs back before deploying — build and verify in a cron builder, then confirm the command runs standalone with cron's stripped-down environment.

Cron rewards precision and punishes guessing. Spend the extra minute to verify the next handful of fire times, and you'll avoid the 3 AM page that says your nightly job has been running every minute since Tuesday.