Bash and Linux

Read a crontab

easySystem monitoring

Problem statement

Read a crontab and print each job's schedule in plain words, flagging the job that runs every minute, which is almost always a mistake. Cron runs backups, reports and cleanups on nearly every server, and misreading the five time fields is how a nightly job ends up running 1,440 times a day.

crontab.txt

Bash
# m h dom mon dow command
*/5 * * * * /opt/app/health.sh
30 2 * * * /opt/app/backup.sh
0 9 * * 1-5 /opt/app/report.sh
* * * * * /opt/app/cleanup.sh
0 0 1 * * /opt/app/invoice.sh
@reboot /opt/app/start.sh

For each job, print the script name and its schedule in words. Mark the every-minute job with a warning.

Expected output:

◈ DIAGRAM
health.sh every 5 minutes
backup.sh every day at 02:30
report.sh weekdays at 09:00
cleanup.sh EVERY MINUTE <-- is that intended?
invoice.sh day 1 of every month at 00:00
start.sh once, when the machine boots

Hints

Hint 1: The five fields are minute, hour, day of month, month and day of week. * means "every", */5 means "every 5th", and 1-5 in the last field means Monday to Friday.

Approach

Optimal: Cron fields with awk

Covers: the five cron fields, *, */N, ranges 1-5, lists 1,15, @reboot and other shortcuts, crontab -l, awk tests on several fields, printf "%02d".

Five fields, then the command. Each crontab line is a schedule and a command:

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart TB L["30 2 * * * /opt/app/backup.sh"]:::gray L --> MI["minute
30"]:::blue L --> H["hour
2"]:::blue L --> D["day of month
*"]:::gray L --> MO["month
*"]:::gray L --> W["day of week
*"]:::gray L --> C["command"]:::green classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px
Field Allowed Example Means
minute 0-59 30 at minute 30
hour 0-23 2 at 2 am
day of month 1-31 1 on the 1st
month 1-12 * every month
day of week 0-7 (0 and 7 are Sunday) 1-5 Monday to Friday
Symbol Means Example
* every value * in hour = every hour
*/N every Nth value */5 in minute = every 5 minutes
a-b a range 1-5 = Monday to Friday
a,b a list 0,30 = at :00 and :30

A job runs when all fields match the current time. So 30 2 * * * means minute 30 and hour 2, on any day: 02:30 every night.

The classic mistakes.

%%{init: {"flowchart": {"padding": 18, "nodeSpacing": 30, "rankSpacing": 40, "htmlLabels": true}, "themeVariables": {"fontSize": "18px"}}}%% flowchart LR subgraph BAD["runs too often"] direction TB B1["* * * * *
every minute"]:::red ~~~ B2["* 2 * * *
60 times from 02:00"]:::red end subgraph GOOD["what was meant"] direction TB G1["0 * * * *
once an hour"]:::green ~~~ G2["0 2 * * *
once at 02:00"]:::green end BAD ~~~ GOOD classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px style BAD fill:transparent,stroke:#dc2626,stroke-width:2px style GOOD fill:transparent,stroke:#059669,stroke-width:2px
  • * * * * * runs every minute. People write it while testing and forget to change it.
  • * 2 * * * runs every minute from 02:00 to 02:59, 60 times, not once at 2 am. The minute must be set too: 0 2 * * *.
  • If both day of month and day of week are set, the job runs when either matches, not both.

Special shortcuts. @reboot runs once when the machine starts. @daily, @hourly and @weekly are short forms of common schedules (@daily is 0 0 * * *).

Walking through the code. The # Setup: lines only save the sample crontab, so skip past them.

  1. awk skips comments and empty lines, and splits the script name off the command path with split and n.
  2. The tests run from most specific to least: @reboot, all five *, */N in the minute, then fixed minute and hour with different day fields.
  3. printf "%02d:%02d" prints hours and minutes with a leading zero, like 02:30.

Edge cases. This reader only covers common shapes, and prints custom schedule for anything else, such as lists or ranges in the minute field. Cron uses the server's own time zone. A system crontab (/etc/crontab, /etc/cron.d/*) has an extra user column before the command, which shifts the fields by one.

# Setup: save the sample crontab in a fresh temporary folder (on a server: crontab -l)
cd "$(mktemp -d)"
cat > crontab.txt << 'CRON'
# m h dom mon dow command
*/5 * * * * /opt/app/health.sh
30 2 * * * /opt/app/backup.sh
0 9 * * 1-5 /opt/app/report.sh
* * * * * /opt/app/cleanup.sh
0 0 1 * * /opt/app/invoice.sh
@reboot /opt/app/start.sh
CRON

awk '
  /^[[:space:]]*#/ || NF == 0 { next }
  {
    cmd = ($1 == "@reboot") ? $2 : $6
    n = split(cmd, part, "/"); name = part[n]          # just the script name
    if ($1 == "@reboot")                                   s = "once, when the machine boots"
    else if ($1 == "*" && $2 == "*" && $3 == "*" && $4 == "*" && $5 == "*")
                                                           s = "EVERY MINUTE  <-- is that intended?"
    else if ($1 ~ /^\*\/[0-9]+$/ && $2 == "*")             s = "every " substr($1, 3) " minutes"
    else if ($3 == "*" && $4 == "*" && $5 == "*")          s = sprintf("every day at %02d:%02d", $2, $1)
    else if ($3 == "*" && $4 == "*" && $5 == "1-5")        s = sprintf("weekdays at %02d:%02d", $2, $1)
    else if ($4 == "*" && $5 == "*")                       s = sprintf("day %d of every month at %02d:%02d", $3, $2, $1)
    else                                                   s = "custom schedule"
    printf "%-12s %s\n", name, s
  }
' crontab.txt

Interview follow-ups

  • Write the backup job as a systemd timer instead.

    A timer needs two files. backup.service has [Service] Type=oneshot ExecStart=/opt/app/backup.sh. backup.timer has [Timer] OnCalendar=*-*-* 02:30:00 and Persistent=true, and [Install] WantedBy=timers.target. Enable it with systemctl enable --now backup.timer. Timers log to the journal, can catch up on runs missed while the machine was off (Persistent=true), and systemctl list-timers shows when each will run next.

Frequently asked questions

Cron runs jobs with a tiny environment: a short PATH (often just /usr/bin:/bin), no login files, no terminal, and the user's home as the working folder. So commands like aws or node in /usr/local/bin are not found, and relative paths point somewhere else. Use full paths in the script, set PATH at the top of the crontab, and cd to the right folder first. Send output to a log: ... >> /var/log/job.log 2>&1, so you can see the error.

crontab -e edits your own crontab safely and checks the syntax when you save; crontab -l lists it. They are stored under /var/spool/cron, but you should not edit those files directly. System jobs live in /etc/crontab and /etc/cron.d/, which have an extra user field, and in /etc/cron.daily and friends, which hold plain scripts run by run-parts. Check all of them when hunting for a mystery job.

In a crontab line, % means "new line", and everything after the first % is sent to the command as input. So date +%F in a crontab is cut off at the %. Escape it as \%, like date +\%F, or move the command into a script file and call the script from cron. The script approach is cleaner and easier to test.