Skip to main content
Bash · lesson 18 of 22
Bash intermediate Lesson 18 of 22

Also part of: Linux

Cron and Scheduling in Bash

Crontab syntax, cron expressions, at for one-time jobs, systemd timers and logging cron output, with real cron and systemd runs.

By SkillByExample · Updated

Cron is the standard Unix job scheduler. It runs commands at set times: backups at 2 AM, health checks every 5 minutes, reports every Monday. Every example below ran in an ubuntu:24.04 container booted with systemd 255 as PID 1, with the cron and at packages installed, as root, with the clock in UTC. So the jobs really fired, and the journal is real.

Crontab Basics

Each user has one crontab. crontab -e opens it in an editor. In scripts it’s easier to install a whole file with crontab FILE. This one runs date every minute, with the field layout written as comments above it:

# ┌──────── minute (0–59)
# │ ┌────── hour (0–23)
# │ │ ┌──── day of month (1–31)
# │ │ │ ┌── month (1–12 or JAN–DEC)
# │ │ │ │ ┌ day of week (0–7, 0 and 7 = Sunday, or SUN–SAT)
# │ │ │ │ │
  * * * * *  date >> /tmp/tick.log
$ crontab -l
no crontab for root
$ crontab jobs.cron
$ crontab -l
# ┌──────── minute (0–59)
# │ ┌────── hour (0–23)
# │ │ ┌──── day of month (1–31)
# │ │ │ ┌── month (1–12 or JAN–DEC)
# │ │ │ │ ┌ day of week (0–7, 0 and 7 = Sunday, or SUN–SAT)
# │ │ │ │ │
  * * * * *  date >> /tmp/tick.log
$ sleep 125; cat /tmp/tick.log
Mon Oct 12 17:51:01 UTC 2026
Mon Oct 12 17:52:01 UTC 2026
$ crontab -r
$ crontab -l
no crontab for root

Cron checks its tables once a minute, so jobs start at about second :01, never midway through a minute. crontab FILE replaces your whole crontab rather than adding to it. And crontab -r deletes all of it at once, with no prompt. -r sits next to -e on the keyboard, so keep a copy of the file in version control.

Common Cron Expressions

These are the schedules you’ll meet most often in production crontabs. Installing the file checks the syntax of every line:

# Every 5 minutes (*/5 means "every 5th")
*/5 * * * *       /usr/local/bin/poll.sh
# Every day at 2:30 AM
30 2 * * *        /usr/local/bin/backup.sh
# Every weekday (Mon–Fri) at 8:00 AM
0 8 * * 1-5       /usr/local/bin/workday-start.sh
# Every 15 minutes from 9:00 to 17:45 on weekdays
*/15 9-17 * * 1-5 /usr/local/bin/monitor.sh
# 8 AM, 12 PM and 6 PM
0 8,12,18 * * *   /usr/local/bin/report.sh
# First day of every month at midnight
0 0 1 * *         /usr/local/bin/monthly-rollup.sh
# Shortcuts: @reboot, @hourly, @daily, @weekly, @monthly, @yearly
@reboot           /usr/local/bin/startup-tasks.sh
@daily            /usr/local/bin/daily.sh

A one-character slip in a second file, typo.cron:

# Meant "every day at midnight", typed 24 instead of 0
0 24 * * *        /usr/local/bin/daily.sh
$ crontab schedules.cron; echo "exit $?"
exit 0
$ crontab typo.cron; echo "exit $?"
"typo.cron":1: bad hour
errors in crontab file, can't install.
exit 1
$ crontab -l | head -2
# Every 5 minutes (*/5 means "every 5th")
*/5 * * * *       /usr/local/bin/poll.sh

Hours run 0–23, so 24 is rejected. Notice two more things. A failed install leaves the previous crontab in place: schedules.cron is still active. And this cron counts lines from 0, so the error on line 2 is reported as :1. Also, 9-17 in the hour field means “any minute in hours 9 through 17”, so */15 9-17 fires for the last time at 17:45, not 17:00.

Crontab Best Practices

The most common reason cron jobs fail silently is cron’s minimal environment: PATH is just /usr/bin:/bin, there’s no profile and there are no aliases (Practice 1 shows this). So set the environment at the top of the crontab. The MAILTO line below has a trailing comment:

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=ops@example.com    # email errors (requires a configured mail server)

* * * * * echo "MAILTO=[$MAILTO]" >> /tmp/env.log
$ crontab env.cron
$ sleep 65; cat /tmp/env.log
MAILTO=[ops@example.com    # email errors (requires a configured mail server)]

Cron only treats # as a comment at the start of a line. On a NAME=value line, everything after the = is the value, including the spaces and the comment. Cron would try to send mail to that whole string. Move the comment onto its own line. The fixed crontab also adds a job that doesn’t redirect its output, to see where that output goes:

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Email errors here (requires a configured mail server)
MAILTO=ops@example.com

* * * * * echo "MAILTO=[$MAILTO]" >> /tmp/env.log
# No redirect: cron tries to email this job's output
* * * * * /usr/local/bin/backup.sh
$ crontab env-fixed.cron
$ sleep 60; cat /tmp/env.log
MAILTO=[ops@example.com    # email errors (requires a configured mail server)]
MAILTO=[ops@example.com]
$ journalctl -u cron --no-pager -n 3 -o cat
pam_unix(cron:session): session opened for user root(uid=0) by root(uid=0)
(CRON) info (No MTA installed, discarding output)
pam_unix(cron:session): session closed for user root

MAILTO is now just the address. backup.sh printed three lines (two to stdout, one to stderr), but this server has no mail transfer agent, so cron threw them away and left one info line in the journal. That’s the normal state of a server: unless a job redirects its output, the output is gone.

A script can also take care of its own logging, so the crontab line stays short. exec with only redirections changes where the rest of the script writes:

#!/usr/bin/env bash
# /usr/local/bin/nightly.sh
set -euo pipefail

LOG="/var/log/myapp/nightly.log"
mkdir -p "$(dirname "$LOG")"

# Redirect all further output (stdout and stderr) to the log file
exec >> "$LOG" 2>&1

echo "=== Nightly started: $(date) ==="
echo "warning: disk almost full" >&2
echo "=== Nightly finished: $(date) ==="
$ /usr/local/bin/nightly.sh
$ cat /var/log/myapp/nightly.log
=== Nightly started: Mon Oct 12 17:55:08 UTC 2026 ===
warning: disk almost full
=== Nightly finished: Mon Oct 12 17:55:08 UTC 2026 ===

Run by hand, the script prints nothing: after the exec, both streams go to the log, and the stderr warning lands there in order. The script behaves the same way whether cron, a systemd unit or a person starts it.

Preventing Overlapping Cron Jobs

If a job runs longer than its interval, cron starts a second copy while the first is still running. That can corrupt data or process it twice. flock takes a lock atomically, so the second copy can see the first and exit:

#!/usr/bin/env bash
# /usr/local/bin/locked.sh
LOCKFILE="/tmp/$(basename "$0").lock"

# Open FD 9 on the lockfile, then try to take an exclusive lock
exec 9>"$LOCKFILE"
if ! flock -n 9; then
  echo "pid $$: another instance is running. Exiting." >&2
  exit 0
fi
# The lock is released automatically when the script exits
echo "pid $$: running"
sleep 3
echo "pid $$: done"
$ locked.sh & sleep 1; locked.sh; wait
pid 249: running
pid 254: another instance is running. Exiting.
pid 249: done

The second copy started while the first held the lock, so flock -n (non-blocking) failed at once. The lock belongs to file descriptor 9, which closes when the process exits, even if it’s killed, so a crash never leaves a stale lock behind. You can also do the same thing without touching the script, by putting it in the crontab line: */5 * * * * flock -n /tmp/monitor.lock /usr/local/bin/monitor.sh. Practice 2 runs it.

System-Wide Cron Directories

On Debian/Ubuntu and RHEL-based systems, packages and configuration management drop scripts into /etc/cron.hourly, /etc/cron.daily, /etc/cron.weekly and /etc/cron.monthly instead of editing a crontab. run-parts runs each directory, and --test lists what it would run. Three copies of the same script are installed below, named and permissioned differently:

$ grep run-parts /etc/crontab
17 *	* * *	root	cd / && run-parts --report /etc/cron.hourly
25 6	* * *	root	test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.daily; }
47 6	* * 7	root	test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.weekly; }
52 6	1 * *	root	test -x /usr/sbin/anacron || { cd / && run-parts --report /etc/cron.monthly; }
$ install -m 755 backup.sh /etc/cron.daily/myapp-backup
$ install -m 755 backup.sh /etc/cron.daily/myapp-report.sh
$ install -m 644 backup.sh /etc/cron.daily/myapp-cleanup
$ run-parts --test /etc/cron.daily
/etc/cron.daily/apt-compat
/etc/cron.daily/dpkg
/etc/cron.daily/myapp-backup

Only myapp-backup would run. Debian’s run-parts skips any name containing a dot, so myapp-report.sh is ignored without a warning, and it skips files that aren’t executable, so myapp-cleanup is ignored too. Run run-parts --test after every deploy into these directories. The /etc/crontab lines also show when the directories run: daily at 06:25 and weekly on Sunday at 06:47. When anacron is installed, it takes over those runs instead.

at — One-Time Scheduled Jobs

at runs a command once, at a set time, with no permanent crontab entry. It reads the command from standard input and needs the atd service running:

$ echo "/usr/local/bin/restart.sh" | at now + 10 minutes
warning: commands will be executed using /bin/sh
job 1 at Mon Oct 12 18:05:00 2026
$ echo "/usr/local/bin/cleanup.sh" | at midnight
warning: commands will be executed using /bin/sh
job 2 at Tue Oct 13 00:00:00 2026
$ atq
2	Tue Oct 13 00:00:00 2026 a root
1	Mon Oct 12 18:05:00 2026 a root
$ atrm 1
$ atq
2	Tue Oct 13 00:00:00 2026 a root
$ echo 'echo "ran at $(date +%T) as $(whoami)" > /tmp/at.out' | at now
warning: commands will be executed using /bin/sh
job 3 at Mon Oct 12 17:55:00 2026
$ sleep 5; cat /tmp/at.out
ran at 17:55:12 as root

at rounds every time down to the minute: job 3 is listed as 17:55:00, but it actually ran at 17:55:12, when atd picked it up. The warning means what it says: the job runs under /bin/sh (dash on Ubuntu), not bash. If a job needs bash syntax, have it run a bash script. Unlike cron, at keeps the environment of the shell that scheduled it, including PATH and the current directory. And % is safe here, because at doesn’t treat it specially the way cron does.

systemd Timers

systemd timers are the modern alternative to cron on Linux systems running systemd. They send output to the journal, catch up on runs missed while the machine was off, and support monotonic timers and dependencies. A timer is a pair of units. The service (/etc/systemd/system/myapp-backup.service) says what to run:

[Unit]
Description=MyApp Backup
After=network.target

[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup.sh
StandardOutput=journal
StandardError=journal

The timer (/etc/systemd/system/myapp-backup.timer) says when. This version has a bug that is easy to miss:

[Unit]
Description=Run MyApp Backup daily at 2 AM

[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=300    # spread load — run within a random 5-minute window
Persistent=true           # run immediately on next boot if the last run was missed

[Install]
WantedBy=timers.target
$ systemctl enable --now myapp-backup.timer
Created symlink /etc/systemd/system/timers.target.wants/myapp-backup.timer → /etc/systemd/system/myapp-backup.timer.
$ systemctl show myapp-backup.timer -p RandomizedDelayUSec -p Persistent
RandomizedDelayUSec=0
Persistent=no
$ systemd-analyze verify /etc/systemd/system/myapp-backup.timer; echo "exit $?"
/etc/systemd/system/myapp-backup.timer:6: Failed to parse sec value, ignoring: 300    # spread load — run within a random 5-minute window
/etc/systemd/system/myapp-backup.timer:7: Failed to parse boolean value, ignoring: true           # run immediately on next boot if the last run was missed
exit 0

The timer enabled without complaint, but systemctl show reports both settings at their defaults: no random delay, and no catch-up after downtime. systemd unit files, like crontabs, only allow # comments on their own line. A trailing comment becomes part of the value, so 300 # spread load… isn’t a number and true # run… isn’t a boolean. systemd logs “ignoring” and drops the whole setting. The same two lines are in the journal from when the unit was loaded:

$ journalctl -b --no-pager -o cat | grep 'Failed to parse' | tail -2
/etc/systemd/system/myapp-backup.timer:6: Failed to parse sec value, ignoring: 300    # spread load — run within a random 5-minute window
/etc/systemd/system/myapp-backup.timer:7: Failed to parse boolean value, ignoring: true           # run immediately on next boot if the last run was missed

systemd-analyze verify still exits 0 after printing those errors, so a CI step that only checks its exit status would pass this unit. Check its output instead (fail if it prints anything). The fix is to put each comment on its own line:

[Unit]
Description=Run MyApp Backup daily at 2 AM

[Timer]
OnCalendar=*-*-* 02:00:00
# Spread load: run within a random 5-minute window
RandomizedDelaySec=300
# Run at the next boot if the machine was off at 2 AM
Persistent=true

[Install]
WantedBy=timers.target
$ systemd-analyze verify /etc/systemd/system/myapp-backup.timer
$ systemctl daemon-reload
$ systemctl show myapp-backup.timer -p RandomizedDelayUSec -p Persistent
RandomizedDelayUSec=5min
Persistent=yes
$ systemctl list-timers myapp-backup.timer
NEXT                        LEFT LAST PASSED UNIT               ACTIVATES
Tue 2026-10-13 02:02:22 UTC   8h -         - myapp-backup.timer myapp-backup.service

1 timers listed.
Pass --all to see loaded but inactive timers, too.
$ systemctl start myapp-backup.service
$ journalctl -u myapp-backup.service --no-pager -n 6
Oct 12 17:57:00 724c098ae660 systemd[1]: Starting myapp-backup.service - MyApp Backup...
Oct 12 17:57:00 724c098ae660 backup.sh[452]: backup starting
Oct 12 17:57:00 724c098ae660 backup.sh[452]: disk almost full
Oct 12 17:57:00 724c098ae660 backup.sh[452]: backup done
Oct 12 17:57:00 724c098ae660 systemd[1]: myapp-backup.service: Deactivated successfully.
Oct 12 17:57:00 724c098ae660 systemd[1]: Finished myapp-backup.service - MyApp Backup.

verify is silent now, and both settings take effect. The next run is 02:02:22 rather than 02:00:00 because the 5-minute random delay is already applied. systemctl start on the service runs the job immediately, which is how you test it without waiting for 2 AM. Both stdout and stderr landed in the journal, tagged with the script name and PID. No redirect was needed.

systemd-analyze calendar shows when any OnCalendar= expression will next fire, so you don’t have to guess:

$ systemd-analyze calendar --iterations=2 'Mon *-*-* 09:00:00' '*:0/5' 'monthly'
Normalized form: Mon *-*-* 09:00:00
    Next elapse: Mon 2026-10-19 09:00:00 UTC
       From now: 6 days left
   Iteration #2: Mon 2026-10-26 09:00:00 UTC
       From now: 1 week 6 days left

  Original form: *:0/5
Normalized form: *-*-* *:00/5:00
    Next elapse: Mon 2026-10-12 18:00:00 UTC
       From now: 3min 13s left
   Iteration #2: Mon 2026-10-12 18:05:00 UTC
       From now: 8min left

  Original form: monthly
Normalized form: *-*-01 00:00:00
    Next elapse: Sun 2026-11-01 00:00:00 UTC
       From now: 2 weeks 5 days left
   Iteration #2: Tue 2026-12-01 00:00:00 UTC
       From now: 1 month 18 days left

The normalized form is the full DayOfWeek Year-Month-Day Hour:Minute:Second syntax that every shorthand expands to. *:0/5 means “every 5 minutes”, and monthly means midnight on the 1st.

Logging Cron Output

Without a redirect, cron’s output is lost, as the MAILTO example showed. Three patterns cover most cases. Here they all run every minute against the same backup.sh, which prints two lines to stdout and one to stderr:

# Pattern 1: redirect in the crontab line itself (simplest)
* * * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

# Pattern 2: prepend a timestamp to every output line
# (one line only, and every % escaped as \% — see below)
* * * * * /usr/local/bin/backup.sh 2>&1 | while IFS= read -r line; do echo "$(date '+\%Y-\%m-\%d \%H:\%M:\%S') $line"; done >> /var/log/backup-ts.log

# Pattern 3: send to syslog/journald via logger
* * * * * /usr/local/bin/backup.sh 2>&1 | logger -t backup -p local0.info
$ crontab logging.cron
$ sleep 65; cat /var/log/backup.log
backup starting
disk almost full
backup done
$ cat /var/log/backup-ts.log
2026-10-12 17:56:01 backup starting
2026-10-12 17:56:01 disk almost full
2026-10-12 17:56:01 backup done
$ journalctl -t backup --no-pager
Oct 12 17:56:01 724c098ae660 backup[299]: backup starting
Oct 12 17:56:01 724c098ae660 backup[299]: disk almost full
Oct 12 17:56:01 724c098ae660 backup[299]: backup done

All three captured the stderr line, because 2>&1 merges it in first. Pattern 1 has no timestamps, so you can’t tell which run wrote which line. Pattern 3 gets timestamps and central log shipping from journald for free.

Pattern 2 breaks two crontab rules that a shell script wouldn’t. A crontab has no line continuation, so a \ at the end of a line doesn’t join it to the next one, and crontab refuses the file (bad minute). And cron cuts the command at the first unescaped % and feeds the rest to it as standard input. So date '+%Y' must be written date '+\%Y', or the job runs a truncated command and never writes the log (Practice 3 shows exactly what runs).

Monitoring Cron Jobs

Detecting that a job has stopped running matters as much as logging what it does when it runs. The job touches a sentinel file at the end of each successful run (touch /var/run/myjob.lastrun), and a separate check alerts when the file gets too old:

#!/usr/bin/env bash
# check-lastrun.sh: alert if the daily job has not run within 25 hours
last_run=$(stat -c '%Y' /var/run/myjob.lastrun 2>/dev/null || echo 0)
now=$(date +%s)
age=$(( now - last_run ))

if (( age > 90000 )); then   # 25 hours: catches a daily job that missed one run
  echo "ALERT: myjob has not run for $(( age / 3600 )) hours" >&2
  exit 1
fi
$ touch /var/run/myjob.lastrun; bash check-lastrun.sh; echo "exit $?"
exit 0
$ touch -d "30 hours ago" /var/run/myjob.lastrun; bash check-lastrun.sh; echo "exit $?"
ALERT: myjob has not run for 30 hours
exit 1

A fresh sentinel passes silently. One backdated by 30 hours fails with exit status 1, which any monitoring system can act on. Run the check from a different scheduler or host than the job itself: if cron is down, a check that cron runs is down too.

Practice

Solutions 1, 2 and 4 ran in an ubuntu:24.04 container with the cron package installed and cron -f as the main process, so the jobs really fired. Solution 3 ran in the systemd-booted container used for the lesson body, so it could read cron’s journal.

  1. Prove the “minimal environment” claim. Install a crontab entry that runs every minute and appends $PATH to a log. Then add the PATH= line from “Crontab Best Practices” at the top of the crontab and look again.

    Solution

    The first crontab:

    * * * * * echo "default: PATH=$PATH" >> /tmp/env.log 2>&1

    One minute later, replace it with:

    PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
    * * * * * echo "with PATH line: PATH=$PATH" >> /tmp/env.log 2>&1
    $ echo "interactive: PATH=$PATH"
    interactive: PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
    $ cat /tmp/env.log
    default: PATH=/usr/bin:/bin
    with PATH line: PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

    Your terminal and cron are running on the same machine, but cron’s jobs get only /usr/bin:/bin. Anything installed in /usr/local/bin would be “command not found” until the crontab sets PATH itself.

  2. Make the overlap problem happen. Write /w/slow.sh, which takes 80 seconds, schedule it every minute behind flock -n, and leave it running for four minutes.

    Solution
    #!/usr/bin/env bash
    echo "$(date +%H:%M:%S) start (pid $$)"
    sleep 80
    echo "$(date +%H:%M:%S) end   (pid $$)"
    * * * * * flock -n /tmp/slow.lock /w/slow.sh >> /tmp/slow.log 2>&1
    $ cat /tmp/slow.log
    10:03:01 start (pid 119)
    10:04:21 end   (pid 119)
    10:05:01 start (pid 241)
    10:06:21 end   (pid 241)

    Cron did fire at 10:04, but the 10:03 run still held the lock, so flock -n exited without running the script. There is no “10:04 start” line, and no two runs ever overlap. The % signs are fine here because they are inside the script, not on the crontab line.

  3. Take Pattern 2 from “Logging Cron Output” and remove the backslashes before every %. Install it, wait a minute, and work out what cron actually ran.

    Solution
    $ crontab unescaped.cron
    $ crontab -l
    * * * * * /usr/local/bin/backup.sh 2>&1 | while IFS= read -r line; do echo "$(date '+%Y-%m-%d %H:%M:%S') $line"; done >> /var/log/backup-ts.log
    $ sleep 65; cat /var/log/backup-ts.log
    cat: /var/log/backup-ts.log: No such file or directory
    $ journalctl -u cron --no-pager -o cat -n 3
    (CRON) info (No MTA installed, discarding output)
    pam_unix(cron:session): session closed for user root
    (root) RELOAD (crontabs/root)
    $ crontab -l | cut -d'%' -f1 | cut -d' ' -f6- > /tmp/what-cron-ran; cat /tmp/what-cron-ran
    /usr/local/bin/backup.sh 2>&1 | while IFS= read -r line; do echo "$(date '+
    $ sh /tmp/what-cron-ran
    /tmp/what-cron-ran: 2: Syntax error: Unterminated quoted string

    crontab installed the line without complaint, and cron fired it, but the log file was never created. Cron passed only the text before the first % to /bin/sh, which is an unclosed ' quote. The shell failed with a syntax error before running anything, and that error message was the “output” cron discarded. Put the backslashes back, or move the pipeline into a script file, where % has no special meaning.

  4. Copy check-lastrun.sh, point it at /tmp/myjob.lastrun, and add an else branch that prints OK. Test it three ways: with a fresh sentinel, with one backdated by touch -d "26 hours ago", and with no sentinel at all.

    Solution
    last_run=$(stat -c '%Y' /tmp/myjob.lastrun 2>/dev/null || echo 0)
    now=$(date +%s)
    age=$(( now - last_run ))
    
    if (( age > 90000 )); then
      echo "ALERT: myjob has not run for $(( age / 3600 )) hours" >&2
    else
      echo "OK: myjob ran $(( age / 3600 )) hours ago"
    fi
    $ touch /tmp/myjob.lastrun; bash check-lastrun.sh
    OK: myjob ran 0 hours ago
    $ touch -d "26 hours ago" /tmp/myjob.lastrun; bash check-lastrun.sh
    ALERT: myjob has not run for 26 hours
    $ rm /tmp/myjob.lastrun; bash check-lastrun.sh
    ALERT: myjob has not run for 497698 hours

    A missing sentinel makes the || echo 0 fallback kick in, which counts the age from 1970. That is still an alert, which is what you want for a job that has never run.

What’s Next

The next tutorial covers Bash security — safe temp files, avoiding command injection, handling secrets, and file permissions.

Frequently Asked Questions

Why does my cron job not run even though the crontab looks correct?
Cron runs with a minimal environment — a PATH of only /usr/bin:/bin, no aliases, no sourced .bashrc. Always use absolute paths for commands and scripts. Add PATH=/usr/local/bin:/usr/bin:/bin at the top of your crontab.
How do I prevent multiple instances of a cron job from running at the same time?
Use a lockfile with flock: 0 * * * * flock -n /tmp/myjob.lock /path/to/script.sh. If the lock is held, the new instance exits immediately.
What is the difference between cron and systemd timers?
systemd timers are more powerful — they support monotonic timers (run X minutes after boot), missed-run catch-up, better logging via journald, and dependencies on other units. crontab is simpler and universally available.
Can I put a comment at the end of a line in a crontab or a systemd unit file?
No. Both only treat # as a comment at the start of a line. In a crontab, a trailing comment on a variable line such as MAILTO= becomes part of the value. In a systemd unit, numeric or boolean settings fail to parse and the line is ignored, while for string settings like Description= or ExecStart= the comment becomes part of the value. Put every comment on its own line.