</>
Back to Blog
Time 2026-06-04 7 min read

Crontab, Properly Explained

Paul Vixie's cron from 1987 set the conventions every modern scheduler still inherits β€” five fields, asterisks for any-value, ranges and lists, the @daily macros. Cron is older than the web, older than Linux, almost as old as me, and the small ways its semantics surprise people are responsible for an outsized share of production incidents in scheduled jobs.

CronCrontabSchedulingQuartzDST

What it actually is

A crontab line has five whitespace-separated time fields followed by a command:

minute  hour  day-of-month  month  day-of-week   command
0       9     *             *      1-5           /usr/local/bin/run_report

That line means: at minute 0 of hour 9, on every day of every month, when the day-of-week is Monday through Friday, run the report.

Each field accepts:

  • A number β€” 9 means hour 9.
  • A range β€” 1-5 means values 1 through 5.
  • A list β€” 1,3,5 means values 1, 3, and 5.
  • A wildcard β€” * means every valid value for that field.
  • A step β€” */15 means "every 15," 0-30/5 means "0, 5, 10, ..., 30."

Day-of-week is 0–6 with both 0 and 7 meaning Sunday. (Yes, both. It's an old wart.) Some implementations also accept three-letter names: MON, TUE, etc.

That's the entire syntax for vanilla cron. Any expression you can't construct from those rules is using an extension.

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

The single most counterintuitive piece of cron semantics:

0 9 1 * 1   /run.sh

You'd think this means "9am on the 1st of the month, but only if it's a Monday." It doesn't. It means "9am on the 1st of the month, OR every Monday."

Cron applies day-of-month and day-of-week with OR, not AND. If both fields are restricted (neither is *), the job fires on any day that satisfies either one. The above expression fires on the 1st of every month and on every Monday.

The convention is that you set one to * and constrain the other:

  • Every Monday at 9am: 0 9 * * 1
  • The 1st of every month at 9am: 0 9 1 * *
  • The 1st of every month but only when it's a Monday: not expressible in vanilla cron. You either have to script the date check inside the job, use a Quartz expression, or run daily and exit early.

This is the bug that catches everyone exactly once.

The macros

Vixie cron added named shortcuts:

Macro Meaning
@reboot Once at system startup
@yearly, @annually 0 0 1 1 *
@monthly 0 0 1 * *
@weekly 0 0 * * 0
@daily, @midnight 0 0 * * *
@hourly 0 * * * *

These read better than their numeric equivalents and prevent the 0 0 * * * typo where you accidentally wrote 0 * * * *. Use them when they fit. The one to avoid is @reboot β€” its semantics differ across cron implementations, and on systemd-managed hosts it's often outright ignored.

Quartz: the 6-field cousin

The Java ecosystem standardized on a different cron syntax popularized by the Quartz scheduler. The differences:

  • Six fields, not five β€” a leading seconds field.
  • An optional seventh field for year.
  • A ? placeholder for day-of-month / day-of-week, used when the other field is set, to avoid the OR ambiguity above.
  • An L modifier β€” L in day-of-month means "last day of the month," 5L in day-of-week means "last Friday of the month."
  • A W modifier β€” 15W means "the weekday closest to the 15th."
  • A # modifier β€” 1#3 in day-of-week means "the third Monday of the month."

Quartz expressions are strictly more expressive than Vixie cron. They're also incompatible. A Vixie expression pasted into a Quartz config will fail to parse because Quartz expects six fields. A Quartz expression pasted into Linux cron will misbehave because the leading number gets read as minute.

If you see 0 0 9 ? * MON-FRI, that's Quartz. If you see 0 9 * * 1-5, that's Vixie. The shape gives it away.

DST: where cron silently does the wrong thing

Cron jobs run on wall-clock time. On the day clocks spring forward, the missing hour means a job scheduled for that hour either:

  • Doesn't fire at all (most modern Vixie cron implementations).
  • Fires at the next available wall-clock minute (some).

On the day clocks fall back, the repeated hour means a job scheduled for that hour either:

  • Fires once (most modern implementations track UTC progression internally).
  • Fires twice (older implementations).

If you have a job at 0 2 * * * and your timezone observes DST, you should know which behavior your scheduler chooses, and you should usually move the job to a time that doesn't transition β€” 0 5 * * * or 0 12 * * *. If it absolutely must run during the transition, schedule it in UTC and let the tz database do the work.

Better still: if the system supports it (SYSTEMD_OnCalendar, BSD cron's CRON_TZ, or running cron with TZ=UTC), express the schedule in UTC explicitly. Wall-clock cron + DST + scheduled batch jobs is a recipe for the same subtle bug filed every two years.

The off-by-zero problem

Almost every cron expression people write hits minute 0 of an hour. 0 * * * * for hourly. 0 9 * * * for 9am.

This means almost every cron job in the world fires on the same instant. An external API receiving cron-driven traffic gets a thundering herd at :00:00.000 of every minute, hour, and day. Your 5,000 RPS API is really 0 RPS, then 5,000 instantaneous.

The pragmatic answer: jitter your schedules. Pick */5 instead of 0,5,10,15..., schedule the report for 0 7 * * * instead of 0 9 * * *, run hourly jobs at minute 17 instead of minute 0. The system you're hitting will thank you.

(Yes, I'm aware this advice is itself a coordination problem if everyone follows it. Pick a number off the beaten path.)

Common pitfalls

  • The day-of-month / day-of-week OR trap. Constrain one, not both.
  • Empty PATH. Cron jobs run with a minimal environment β€” usually just /usr/bin:/bin. Anything in /usr/local/bin or your shell's PATH won't be found. Either set PATH= at the top of the crontab or use absolute paths.
  • Missing trailing newline. Some cron implementations silently ignore the last line if it doesn't end in \n.
  • Output buffering. A cron job that prints to stdout sends it to the user's local mail; if mail isn't configured, you lose the output entirely. Redirect to a log file or pipe to logger.
  • Long-running jobs overlapping with the next firing. Cron doesn't serialize. Use a lockfile (flock) or a job-runner that does.
  • Quartz/Vixie syntax confusion. They look almost identical and behave very differently.

Practical rules

  • Use crontab -e and crontab -l, never edit /var/spool/cron directly.
  • Set SHELL, PATH, and MAILTO at the top of the crontab.
  • Wrap every command in absolute paths. /usr/bin/curl, not curl.
  • Always redirect stdout and stderr: >> /var/log/myjob.log 2>&1.
  • Never schedule across a DST transition without testing both directions.
  • Avoid minute :00 and hour :00 unless the timing actually matters β€” jitter to anything else.
  • Test new expressions against the next 5 firing times before deploying. Tools like crontab.guru exist for a reason.

Read and write cron expressions

The crontab tool on this site translates expressions to plain English, generates next-N firing times in a chosen timezone, and warns about common pitfalls (DST gaps, day-of-month + day-of-week conflicts). All client-side.

Open the cron tool

Related guides

Keep the session useful with adjacent reading instead of exiting after one article.

View all guides

Cookie Consent

We use cookies to enhance your experience and show relevant ads. You can customize your preferences.