Cron vs systemd timers: cron runs lines, timers run units
The same 03:30 job on both clocks — silent mail versus journal logging, skipped runs versus Persistent=true, and the decision table for where your next job lands.
Own systemd box → timer. One-line user jobs, containers, boxes you did not build → cron.
Cron fails silently — it mails a local mailbox nobody reads. Timers log every run to the journal: journalctl -u mytimer.service.
Persistent=true re-runs jobs missed while the machine was off; cron silently skips them.
THE TWO CLOCKS
The same job — nightly backup at 03:30 — on both clocks, and what each one leaves behind:
CRON · one line
30 3 * * * /usr/local/bin/backup.sh
runs when the wall clock matches. If it fails, the output mails a local mailbox — usually unread. Machine was off at 03:30? Skipped, silently.
did it even fire? journalctl -u cron -n 20 or grep CRON /var/log/syslog
SYSTEMD TIMER · two unit files
# foo.timer
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
# foo.service — the job itself
a first-class unit: status, dependencies, logs per run in the journal. Machine asleep at the hour? Persistent=true runs it once on boot.
check it: systemd-analyze calendar "*-*-* 03:30:00" — the next elapse, before you save
the schedule verdict: cron's five fields are the compact incumbent; OnCalendar is more verbose but strictly more expressive — Mon..Fri 09..17:00:00 is one line, and RandomizedDelaySec stops a thousand machines hammering one server
these clocks re-stamp from the record's own commands — they cannot read your machine's journal; where your journal actually is: sections 04 and 05.
THE PRACTICAL GAPS
- Logging: timers log stdout/stderr to the journal per unit; cron at best mails the local user.
- Missed runs: a timer with
Persistent=trueruns once on boot if its schedule was missed; cron just skips. - Dependencies: timers can wait for
network-online.targetor mount points; cron needs you to hand-roll retry logic in the script. - Syntax: cron is one line; a timer is two files. That is the entire cost of switching.
- Monotonic option:
OnBootSec=15min— wall-clock changes cannot confuse it.
04TEST A TIMER WITHOUT WAITING
Three commands answer everything — list what is scheduled and when it next fires, run the job by hand exactly as the timer would, then read its logs:
systemctl list-timers --all # every timer, next + last run
sudo systemctl start backup.service # fire the job now, same unit as the timer
journalctl -u backup.service -f # watch its output live
Note the split: systemctl start backup.timer arms the schedule; the service is the job. If list-timers shows your timer, systemctl status backup.service is green, and the journal shows your output, the whole chain works.
verify: systemctl list-timers --all → your timer, last + next run listed
05RUN A CRON JOB MANUALLY
Cron jobs run with a stripped environment, which is why "works in my shell, fails in cron" is a genre of bug. To reproduce cron faithfully, run the command through sh with the environment cron would use:
crontab -l # confirm the exact line
env -i SHELL=/bin/sh PATH=/usr/bin:/bin sh -c '/usr/local/bin/backup.sh'
grep CRON /var/log/syslog | tail # did cron even fire it? (Debian/Ubuntu)
journalctl -u cron -n 20 # same, on systemd distros
That env -i line is the honest manual test: a bare environment with cron's default PATH. The two cron classics — a bare PATH and an unquoted % (cron treats it as a newline) — both show up immediately in that test.
verify: journalctl -u cron -n 20 → your command's CRON entry
"MY TIMER ISN'T TRIGGERING"
A timer that looks dead is usually its service failing instantly. Four causes, in the order to check them:
- Service and timer names do not match.
foo.timerfiresfoo.service— a typo or a renamed unit means the timer "runs" into nothing.systemctl cat foo.timershows exactly what it targets. - Edited but not reloaded. After changing a unit file:
sudo systemctl daemon-reload && sudo systemctl restart foo.timer. Without this your new schedule is not live. - Wrong status command.
systemctl status foo.timeralways shows active even when idle — readlist-timersinstead. - The service it fires is failing instantly.
journalctl -u foo.service --since -1hshows the crash thatlist-timershides.
verify: journalctl -u foo.service --since -1h → the crash list-timers hides
THE DECISION TABLE
| cron | systemd timer | |
|---|---|---|
| Setup | one crontab line | two unit files |
| Logging | local mail, usually unread | journal, per unit |
| Missed run (machine off) | skipped | runs once with Persistent=true |
| Dependencies / ordering | none — DIY in script | full unit dependencies |
| Randomised delay | DIY with $RANDOM | RandomizedDelaySec= |
| Test/dry-run schedule | no | systemd-analyze calendar |
| Exists everywhere | yes — containers, BSDs, embedded | needs systemd (PID 1) |
| User jobs without root | crontab -e | systemd --user units |
Rules of thumb: shipping a job on your own systemd machine → timer. Quick personal reminder or a box you did not build → cron. Anything with dependencies, retries, or a need to know whether it actually ran → timer, always. The classic pattern — a nightly rsync backup under a timer with Persistent=true — guarantees the backup happens even if the machine was asleep at the scheduled minute, which cron simply cannot offer.
end of the two clocks · nothing fetched · everything local