Cron vs systemd timers: which should you use?

Cron vs systemd timers: which should you use?

Cron vs systemd timers compared: syntax, logging, catch-up runs and dependencies — plus a decision table so your next scheduled job lands in the right place.

HUMANby @mrsaynothing45d6 min read

The 10-second checklist

new scheduled job one decision first cron one-line user jobs · containers · BSDs works in shell, fails in cron? bare PATH or unquoted % did it even fire? journalctl -u cron -n 20 systemd timer anything you own machine off at fire time? Persistent=true not triggering? systemctl list-timers --all watch it run journalctl -u foo.service -f check the schedule before you save it: systemd-analyze calendar
fig 1 — the decision map. Cron if it's not yours; timer if it is. Click a branch for the details.

What is the difference between cron and a systemd timer?

Cron runs lines; timers run units.

Cron is a daemon that reads a table of lines — five time fields and a command — and runs each command when the wall clock matches. That is the whole model. It has no concept of a job as an object: no unit, no status, no dependencies, no log entry of its own.

A systemd timer is a unit file (foo.timer) that fires another unit file (foo.service) when its schedule matches. The job is a first-class object with start, status, logs, and failure tracking like any other service. Scheduling is either calendar-based (cron-like) or monotonic (OnBootSec=15min, which wall-clock changes cannot confuse).

The practical consequences:

  • Logging: timers log stdout/stderr to the journal per unit; cron at best mails the local user.
  • Missed runs: a timer with Persistent=true runs once on boot if its schedule was missed; cron just skips.
  • Dependencies: timers can wait for network-online.target or 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.

Which schedule syntax is easier — crontab or OnCalendar?

Cron's five fields are the compact incumbent: */15 * * * * is every 15 minutes, and most admins can read them in their sleep. Systemd's OnCalendar= is more verbose but strictly more expressive, and systemd-analyze calendar will tell you the next runs before you commit — cron has no equivalent dry-run.

# Cron: every day at 03:30
30 3 * * * /usr/local/bin/backup.sh

# systemd: the same schedule, verifiable before you save it
systemd-analyze calendar "*-*-* 03:30:00"
# -> Next elapse: Fri 2026-09-11 03:30:00 ...

OnCalendar handles things cron genuinely cannot express cleanly: Mon..Fri *-*-* 09..17:00:00 (weekdays, business hours), or *:0/15 with RandomizedDelaySec=10m to stop a thousand machines from hammering a server at the same instant.

How do I test a systemd 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

How do I run 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 same 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

Most silent cron failures are a bare PATH or an unquoted % in the command (cron treats % as a newline) — and both show up immediately in the env -i test above.

verify: journalctl -u cron -n 20 → your command's CRON entry

Why is my systemd timer not triggering?

A timer that looks dead is usually its service failing instantly.

Four causes cover almost every case, in the order to check them:

  1. Service and timer names do not match. foo.timer fires foo.service — a typo'd OnFailure or a renamed unit means the timer "runs" into nothing. systemctl cat foo.timer shows exactly what it targets.
  2. The timer was 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.
  3. Persistent=true without OnCalendar semantics you expect — or you are checking systemctl status foo.timer (always shows active even when idle) instead of list-timers.
  4. The service it fires is failing instantly, so the timer looks dead. journalctl -u foo.service --since -1h will show the crash that list-timers hides.

verify: journalctl -u foo.service --since -1h → the crash list-timers hides

And when you do look at the logs, the journalctl cheat sheet covers the filters (-u, --since, -f) that make this fast.

Cron vs systemd timers: the decision table

all basics logging catch-up deps timing debug portable
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. A common pattern is a timer running a maintenance job — say a nightly rsync backup — where Persistent=true guarantees the backup happens even if the machine was asleep at the scheduled minute, which cron simply cannot offer.