DevKitHub

Time and scheduling

Cron expressions, and the rule that makes jobs run twice

4 min read

Cron is a small format with one genuinely surprising rule in it. This is that rule, plus the syntax worth knowing around it.

A standard cron expression is five whitespace-separated fields. In order: minute, hour, day of month, month, day of week.

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)
│ │ │ │ │
30 9 * * 1-5   →  09:30, Monday to Friday

Each field takes a star for "every value", a single number, a range with a hyphen, a list with commas, or a step with a slash. */15 in the minute field means every fifteenth minute; 0-12/4 in the hour field means midnight, 04:00, 08:00 and noon. Sunday can be written as either 0 or 7, which is the one place the format overlaps itself.

The rule that surprises people

When both the day-of-month and day-of-week fields are restricted — when neither is a star — cron runs the job when either matches. Not both.

text
0 0 1 * 1

# Reads like: "midnight on the 1st, if it is a Monday"
# Actually:   "midnight on the 1st, AND midnight every Monday"

That expression fires about five times a month rather than once every few years. The behaviour is documented in the POSIX specification and in the crontab man page, and it is still the single most misread part of the format, because the plain-English reading of the expression is the opposite of what it does.

When one of the two fields is a star, only the other applies, and the expression behaves the way it reads. This is why 0 0 * * 1 (every Monday) and 0 0 1 * * (the 1st of the month) are both unambiguous — and why mixing them is not.

Five fields, or six?

Standard Unix cron has five fields. Quartz — used by Spring, and by a lot of Java scheduling — has six, with seconds at the front, and optionally seven with a year at the end. Kubernetes CronJobs use the five-field form.

The two are not distinguishable by a parser without counting fields, and the same string means different times in each. 0 0 5 * * * is 05:00 daily in Quartz and an error in standard cron. Copying a schedule between a Spring service and a crontab is a routine way to move a job by an hour or lose it entirely.

The shortcuts

  • @hourly0 * * * *
  • @daily and @midnight0 0 * * *
  • @weekly0 0 * * 0 (Sunday)
  • @monthly0 0 1 * *
  • @yearly and @annually0 0 1 1 *
  • @reboot — runs once when the daemon starts, and is not a schedule at all

These are clearer than the equivalent five fields for anyone reading the crontab later, with one caveat: @daily means exactly midnight, and if every service on a host uses it they all start at once.

Time zones and daylight saving

A cron daemon runs in the time zone of the machine it is on, unless it is configured otherwise. That is worth confirming before relying on an exact hour, because it means the same crontab behaves differently on a server set to UTC and one set to local time.

Daylight saving makes it worse in a way that has no clean fix. When the clock jumps forward, a job scheduled inside the skipped hour does not run that day. When it goes back, an hour repeats and the job runs twice. Most implementations try to compensate for one case and not the other, and they do not agree with each other about which.

Overlapping runs

Cron starts a job on schedule regardless of whether the previous run has finished. A task scheduled every five minutes that occasionally takes seven will quietly end up with two copies running, then three, and the failure usually surfaces as duplicated work or a deadlock rather than as anything mentioning cron.

The standard fix is a lock file, and flock does it in one line with no extra tooling:

bash
*/5 * * * * /usr/bin/flock -n /tmp/sync.lock /opt/app/sync.sh
-n means fail immediately rather than queue behind the running copy.

Whether skipping or queueing is correct depends on the job. A sync that reads current state should skip; a queue drain that must process everything should probably queue, or run continuously rather than on a timer.

The environment is not your shell

The single most common reason a cron job works when run by hand and fails on schedule: cron runs with a nearly empty environment. There is no .bashrc, no .profile, and PATH is typically just /usr/bin:/bin.

Anything installed through a version manager — nvm, pyenv, rbenv, asdf — is invisible. node: command not found in a cron log almost always means this rather than a missing install.

  • Use absolute paths to every binary: /usr/local/bin/node, not node.
  • Set PATH= explicitly at the top of the crontab if several jobs need it.
  • cd into the working directory first — cron starts in the user's home, not the project.
  • Redirect output. Without >> /var/log/job.log 2>&1 the daemon tries to email it, and on most servers that mail goes nowhere.

One more detail that catches people: a literal % in a crontab command is interpreted as a newline. A date +%Y-%m-%d in a cron line has to be written as date +\%Y-\%m-\%d, and forgetting it truncates the command at the first percent sign.

Cron Expression ParserPaste an expression to see what it means in plain English and exactly when it next fires. It flags the both-day-fields case explicitly.

Tools for this