Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Scheduling with Crontab: The Syntax, and Why Your Job Did Not Run

Sean

Platform Writer

Aug 26, 2026
8 min read

crontab -e opens your personal crontab, and a line like 0 3 * * * /usr/bin/python3 /opt/app/backup.py runs that script at 03:00 daily. The syntax takes five minutes to learn. Everything that goes wrong afterwards comes from cron’s environment being almost nothing like your shell’s.

Scheduling with Crontab: The Syntax, and Why Your Job Did Not Run

Cron is old, reliable, and unhelpful when something goes wrong. It does not warn you that your command was not found, it does not tell you your schedule never matches, and by default it mails errors to a local mailbox nobody reads.

So the syntax section here is short, and the section on why jobs fail is long, because that is where the time actually goes.

Table of contents

The five fields

*  *  *  *  *  command
|  |  |  |  |
|  |  |  |  +-- day of week   (0-7, both 0 and 7 are Sunday)
|  |  |  +----- month         (1-12)
|  |  +-------- day of month  (1-31)
|  +----------- hour          (0-23)
+-------------- minute        (0-59)

The operators, which cover essentially every schedule you will need:

  • * — every value.
  • */5 — every fifth value. In the minute field, every five minutes.
  • 1,15,30 — a specific list.
  • 9-17 — a range.
  • 0 9-17/2 * * 1-5 — combined: every two hours between 09:00 and 17:00, Monday to Friday.
*/15 * * * *      every 15 minutes
0 * * * *         hourly, on the hour
0 3 * * *         03:00 daily
0 3 * * 0         03:00 on Sundays
0 3 1 * *         03:00 on the first of the month
@reboot           once at boot

One genuine trap in the syntax itself: if you specify both day-of-month and day-of-week as something other than *, cron treats it as OR, not AND. 0 3 1 * 1 runs on the first of the month and on every Monday — not only on Mondays that fall on the first.

When in doubt, write the expression out in plain English before saving it. Most schedule mistakes are readable if you say them aloud.

The PATH problem, which is most failures

This is the single most common cause of a job that works when you run it by hand and does nothing on schedule.

Cron runs with a minimal environment. PATH is typically just /usr/bin:/bin — not your shell’s PATH, no ~/.bashrc, no virtualenv activation, no nvm, no language version manager. A command that resolves fine at your prompt is simply not found.

The fix is to use absolute paths for everything:

# fails silently
0 3 * * * python3 /opt/app/backup.py

# works
0 3 * * * /usr/bin/python3 /opt/app/backup.py

Find the absolute path with which python3 in a normal shell, and use exactly that.

You can also set PATH at the top of the crontab, which applies to every job below it:

PATH=/usr/local/bin:/usr/bin:/bin
SHELL=/bin/bash

0 3 * * * python3 /opt/app/backup.py

For anything with real environment requirements — a virtualenv, a Node version, a set of variables — call a wrapper script rather than fighting cron:

#!/bin/bash
set -euo pipefail
source /opt/app/venv/bin/activate
cd /opt/app
exec python backup.py

Then the crontab line is 0 3 * * * /opt/app/run-backup.sh, and the environment is defined somewhere you can read and test.

Capturing output, so failures are visible

By default cron mails a job’s output to the local user. On a server with no mail configured, that output goes nowhere, and a job that has been failing for six weeks looks exactly like a job that has been working.

Always redirect:

0 3 * * * /opt/app/run-backup.sh >> /var/log/backup.log 2>&1

The 2>&1 must come after the >>, and it is the part that captures errors. Redirecting only stdout logs the successful output and discards the message explaining the failure.

>/dev/null 2>&1 silences a job entirely. It is appropriate for something genuinely noisy and harmless, and it is also how jobs get forgotten. Prefer a log file with rotation:

/var/log/backup.log {
    weekly
    rotate 4
    compress
    missingok
    notifempty
}

Confirm cron is at least trying to run the job — this shows the attempt regardless of whether the command succeeded:

grep CRON /var/log/syslog | tail -20
# or
journalctl -u cron --since "1 hour ago"

If the job does not appear there, cron never fired it, and the problem is your schedule or the crontab you edited. If it appears and the log is empty, the command ran and failed.

The other reliable traps

Percent signs. In a crontab, % means newline. This bites anyone using date formatting:

# broken -- cron truncates at the first %
0 3 * * * /opt/backup.sh > /backups/$(date +%Y-%m-%d).sql

# escaped
0 3 * * * /opt/backup.sh > /backups/$(date +\%Y-\%m-\%d).sql

The wrong crontab. crontab -e edits your crontab. sudo crontab -e edits root’s. A job placed in one while you inspect the other is a genuinely confusing half hour. sudo crontab -l -u username lists a specific user’s.

A missing final newline. Some cron implementations ignore the last line if the file does not end with one. crontab -e normally handles this; a crontab installed from a file may not.

Timezone. Cron uses the system timezone. On a UTC server, 0 9 * * * is 09:00 UTC, which is not 09:00 where you are. Check with timedatectl. Some cron implementations support a CRON_TZ=Europe/London line at the top.

Overlapping runs. Cron starts the job on schedule regardless of whether the previous run finished. A five-minute job on a three-minute schedule eventually has many copies running. Guard it with a lock:

*/5 * * * * /usr/bin/flock -n /tmp/job.lock /opt/app/run.sh

flock -n exits immediately if the lock is held, which is almost always what you want.

Where cron is the wrong tool

Cron is excellent at running a command on a machine at a time. It is poor at several things people ask of it:

  • Knowing whether the job succeeded. There is no built-in alerting. A dead-man’s-switch service that expects a ping and alerts when it stops is the usual addition.
  • Running exactly once across several servers. Put the same crontab on three machines and the job runs three times.
  • Surviving the machine. A crontab lives on one box. Rebuild it and the schedule is gone unless it is in configuration management.
  • Retries and backoff. A failed run is simply a failed run until the next scheduled time.

systemd timers cover some of this better — real logging via journald, dependency ordering, and Persistent=true to run a missed job after downtime. Worth knowing if you are on a systemd distribution and cron is fighting you.

For scheduled work that is part of an application rather than the machine — a nightly report, a sync, a cleanup — a workflow tool with its own execution history and retry handling is generally a better fit than a line in a crontab nobody has read since it was written.

How this fits the rest of the stack

The recurring theme in cron failures is invisibility. The schedule is on one machine, the output goes nowhere, and success and silence look identical. Every fix above is really about making the job observable — absolute paths so it runs, redirected output so you can read it, locks so runs do not pile up.

Scheduled work that matters is usually better off somewhere it leaves a record. RunxBuild runs n8n as a managed tool with its own plan, so scheduled and event-driven workflows have an execution history, retries and logs rather than a silent crontab line — with a managed Postgres beside it for the execution data. Where a job genuinely belongs on the server, the runtime logs are still per-deploy and readable in the dashboard. The RunxBuild hosting calculator shows what the tool and its database come to together.

Useful related references:

FAQ

Why does my cron job work manually but not on schedule?

Almost always PATH. Cron runs with a minimal environment — usually just /usr/bin:/bin — and does not read your shell config, so commands that resolve at your prompt are not found. Use absolute paths for every binary, or set PATH at the top of the crontab.

How do I see if a cron job ran?

Check the system log with grep CRON /var/log/syslog or journalctl -u cron. That records the attempt regardless of outcome. If the job appears there but did nothing, the command ran and failed — which is why every job should redirect output with >> /var/log/job.log 2>&1.

What does the percent sign do in crontab?

In a crontab, % means a newline and truncates the command at that point. This breaks any command using date format strings. Escape them as \%, or move the command into a wrapper script where normal shell rules apply.

How do I stop cron jobs from overlapping?

Wrap the command in flock: */5 * * * * /usr/bin/flock -n /tmp/job.lock /opt/app/run.sh. The -n makes it exit immediately if the previous run still holds the lock, rather than queuing another copy behind it.

Should I use cron or systemd timers?

Timers are better when you want real logging through journald, dependency ordering, or Persistent=true so a job missed during downtime runs on boot. Cron is simpler and universally available. For scheduled work that belongs to an application rather than the machine, a workflow tool with execution history and retries usually beats both.

#scheduling crontab#cron#crontab#linux#scheduled jobs