Compare cron with systemd timers, then copy a working .service and .timer pair. Covers OnCalendar syntax, Persistent=, RandomizedDelaySec, systemctl list-timers and journalctl for logs.
A crontab entry is one line. A systemd timer is two files. That ratio puts most people off, and for a job that only needs to run every night at 2:30 it is a fair complaint. The extra file buys things cron’s one line cannot express: a timer that remembers a missed run, a randomized start, a log you can query by unit name, and a service that can be started by hand for testing.
This guide compares the two on documented behavior, then gives a complete timer you can copy. Everything about unit options comes from the systemd man pages; everything about cron comes from crontab(5). Where the pages say nothing, the guide says so instead of guessing.
How the two differ
| Aspect | cron | systemd timer |
|---|---|---|
| Schedule syntax | Five fields: minute 0-59, hour 0-23, day of month 1-31, month 1-12, day of week 0-7 | OnCalendar= calendar expressions, plus monotonic timers such as OnBootSec= |
| Files | One crontab line | A .timer unit and a matching .service unit |
| Shell | SHELL is set to /bin/sh | No shell in ExecStart=; pipes and redirection need an explicit sh -c |
| Output | Mailed if MAILTO is defined and non-empty | Sent to the journal when the service uses StandardOutput=journal; read with journalctl -u |
| Missed runs | The man page does not describe any catch-up | Persistent=true triggers a missed run when the timer starts again |
| Spreading load | Not in the man page | RandomizedDelaySec= and AccuracySec= |
| Testing a job | Wait for the minute, or run the command by hand | systemctl start name.service runs the job now |
Two cron details bite regularly. A % in a command, unless escaped with a backslash, is changed into newline characters, per the man page. And the environment is thin: SHELL is /bin/sh, with LOGNAME and HOME taken from the owner’s /etc/passwd line.
The two units
A timer does not run commands itself. As the systemd.timer page puts it, for each timer file a matching unit file must exist, describing the unit to activate when the timer elapses, and by default that is a service with the same name as the timer. So notes-backup.timer starts notes-backup.service. The Unit= setting overrides this, but matching names keep things readable.
The service for a scheduled job is normally Type=oneshot. The systemd.service page explains that the manager considers a oneshot unit up after its main process exits, and that it will not enter the “active” state unless RemainAfterExit= is set. That fits a job that runs, finishes and goes quiet.
Reading OnCalendar
Calendar expressions follow the pattern [Weekdays] [Date] [Time] [Timezone], and every part is optional. A missing date means *-*-*, a missing time means 00:00:00. Components accept *, comma lists, .. ranges and / repetition. The systemd.time page also defines shorthands:
| Shorthand | Expands to |
|---|---|
minutely | *-*-* *:*:00 |
hourly | *-*-* *:00:00 |
daily | *-*-* 00:00:00 |
weekly | Mon *-*-* 00:00:00 |
monthly | *-*-01 00:00:00 |
Do not trust an expression you have not checked. systemd-analyze calendar parses and normalizes an expression and calculates when it elapses next, and --iterations shows more than one future time.
systemd-analyze calendar "Mon..Fri *-*-* 02:30:00"
systemd-analyze calendar --iterations=5 daily
A copy-paste example
The following pair runs a backup script at 02:30 every day. Replace the ExecStart= path with your own script, and keep the script executable. Both files go in /etc/systemd/system/.
# /etc/systemd/system/notes-backup.service
[Unit]
Description=Back up the notes folder
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-notes.sh
StandardOutput=journal
# /etc/systemd/system/notes-backup.timer
[Unit]
Description=Run the notes backup every night
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=15min
[Install]
WantedBy=timers.target
Persistent=true stores the time the service was last triggered on disk. When the timer is activated again, the service runs immediately if it should have fired at least once while the timer was inactive. The man page flags two limits: this only works with OnCalendar= timers, and the catch-up run is still subject to RandomizedDelaySec=. That setting delays the timer by a random, evenly distributed amount between zero and the value given, which stops many machines (or many timers) from firing in the same instant. The page describes the delay as added on top of the next determined elapsing time, and it is chosen anew before each iteration unless FixedRandomDelay= is set. With the example above, a run scheduled for 02:30 may start any time up to 02:45. The [Install] line comes from systemd.special, which recommends WantedBy=timers.target for timers that applications install.
Load and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now notes-backup.timer
systemctl list-timers
sudo systemctl start notes-backup.service
daemon-reload reruns generators and reloads unit files. enable --now enables the timer and starts it in the same step. list-timers shows NEXT, LEFT, LAST and PASSED columns, plus the UNIT and the service it ACTIVATES. The last line runs the job once, outside the schedule, which is the quickest way to check the script itself.
Reading the logs
Output lands in the journal, and journalctl filters it by unit.
journalctl -u notes-backup.service
journalctl -u notes-backup.service --since today
journalctl -u notes-backup.service -n 50 -f
The -u flag shows messages for the named unit. --since accepts a full timestamp such as “2012-10-30 18:17:16”, or the words “yesterday”, “today” and “tomorrow”. -n limits the number of lines (10 by default), and -f keeps printing new entries until interrupted. Add -p with a level such as err to hide everything less severe.
Translating common cron lines
Most crontab lines convert directly. The mappings below follow the systemd.time grammar, and systemd-analyze calendar will confirm each one on your own machine before you commit to it.
| Cron line | OnCalendar equivalent |
|---|---|
30 2 * * * | *-*-* 02:30:00 |
*/5 * * * * | *:0/5 |
0 9 * * 1-5 | Mon..Fri 09:00 |
@daily | daily |
0 0 1 * * | monthly |
The @reboot string has no exact twin. The closest match is a monotonic timer such as OnBootSec=, which counts from machine start, and the relative offset is yours to choose.
Mistakes that waste an evening
- Enabling the service instead of the timer. The
[Install]section sits in the.timerfile, so that is the unit to enable. - Expecting
Persistent=to rescue a monotonic timer. The man page says it only has an effect on timers configured withOnCalendar=. - Writing a pipe or redirect straight into
ExecStart=. Shell syntax is not supported there; wrap the command insh -c '...'or move it into a script. - Using a relative path. The first item in
ExecStart=must be an absolute path or a bare filename found in the standard directories. - Skipping
daemon-reloadafter editing a unit file. Reloading unit files is exactly what that command is documented to do, so run it after every change.
Each item is a documented behavior. Several of them fail without any visible complaint, which hides the cause until someone reads the journal. Opinion: that makes journalctl -u the first tool to reach for when a job seems not to have run.
Accuracy and drift
A timer does not necessarily fire on the exact second. AccuracySec= defaults to one minute, and the manual describes the expiry time as landing at a host-specific, randomized but stable position inside that window and synchronized between all local timer units. Setting it to 1us gives the best accuracy. For a nightly backup, the default is fine.
Timers are also not tied to the wall clock alone. The man page distinguishes monotonic timers such as OnBootSec=, which are independent of wall-clock time and timezones, from realtime timers set with OnCalendar=. Opinion: use OnCalendar= for “at this time of day” jobs and monotonic timers for “ten minutes after boot”.
A scheduled job also belongs in a wider plan. Certificate renewal is one candidate for a timer, and x9m8 has a guide to preparing an ACME setup for 64-day certificates. The Raspberry Pi home server walkthrough describes a small always-on machine where jobs like these could live.
Sources
- systemd.timer(5): https://man7.org/linux/man-pages/man5/systemd.timer.5.html
- systemd.time(7): https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html
- systemd.service(5): https://man7.org/linux/man-pages/man5/systemd.service.5.html
- systemd.exec(5), StandardOutput=: https://man7.org/linux/man-pages/man5/systemd.exec.5.html
- systemd.special(7), timers.target: https://man7.org/linux/man-pages/man7/systemd.special.7.html
- systemctl(1): https://man7.org/linux/man-pages/man1/systemctl.1.html
- systemd-analyze(1): https://man7.org/linux/man-pages/man1/systemd-analyze.1.html
- journalctl(1): https://man7.org/linux/man-pages/man1/journalctl.1.html
- crontab(5): https://man7.org/linux/man-pages/man5/crontab.5.html
