systemd Timers vs Cron: Which to Use and How to Migrate
Contents
Use a systemd timer when the job runs on a systemd host and needs something cron lacks, such as a catch-up run after downtime, protection from overlapping runs, a time limit, or its output and exit status in the journal; keep cron for one-line jobs, containers without systemd, and machines that don't run systemd. A timer takes two unit files where a cron job takes one line. Converting also changes more than the schedule: the command no longer runs through a shell, the environment differs, and the two day fields combine differently.
Checked October 24, 2026, against the systemd 262 manual and source, cronie 1.7.2 (Fedora, RHEL, Arch), Debian's cron 3.0pl1-197 (Debian, Ubuntu), and BusyBox 1.37.0 crond (Alpine). For timer options in depth, see the systemd timers guide.
Cron and systemd Timers, Feature by Feature#
| Feature | Cron | systemd timer |
|---|---|---|
| Schedule syntax | Five fields, one-minute resolution, shortcuts like @daily |
OnCalendar= with seconds, years, .. ranges, and ~ for days from the month's end; intervals like OnUnitActiveSec=15min |
| Precision | Starts at the top of the minute | Up to a minute late, because AccuracySec= defaults to 1min |
| Time zone per schedule | cronie: CRON_TZ= lines. Debian, BusyBox: the daemon's zone only |
Any IANA zone per expression: Mon..Fri 22:00 Asia/Tokyo (systemd 235+) |
| Missed runs | Skipped; anacron catches up jobs measured in days | Persistent=true runs one missed activation when the timer next starts (212+) |
| Overlap | cronie, Debian: another copy starts. BusyBox: skipped while the last run is going | Never two copies; calendar activations due during a run collapse into one start when it ends, unless DeferReactivation=yes (257+) |
| Logs | Output mailed through an MTA, only if there is output | Output in the journal; every run's result and exit status recorded |
| Failure hooks | MAILTO, which fires on output, not on failure |
OnFailure= starts any unit; OnSuccess= (249+) |
| Environment | /bin/sh, HOME, LOGNAME, a short PATH, NAME=value lines (BusyBox: only SHELL, PATH, MAILTO) |
No shell, a fixed PATH, Environment=, HOME only with User= |
| Limits | None; wrap with timeout or nice |
TimeoutStartSec= (unlimited for oneshot by default), CPUQuota=, MemoryMax=, Nice= |
| Dependencies | None | Wants= and After= on any unit, such as network-online.target |
| Randomized delay | cronie: RANDOM_DELAY= and ~ ranges (1.6.0+), each chosen once. Others: none |
RandomizedDelaySec= (229+), FixedRandomDelay= (247+), RandomizedOffsetSec= (258+) |
| Per-user jobs | crontab -e; runs while the user is logged out |
systemctl --user; the user manager stops at the last logout unless loginctl enable-linger is set |
| Editing and validation | crontab -e rejects malformed lines (cronie, Debian) |
daemon-reload after edits (systemctl edit does it); systemd-analyze verify and systemd-analyze calendar |
| Seeing what's scheduled | Read every crontab file; no next-run view | systemctl list-timers shows each timer's next and last run |
| Portability | In POSIX; any init, Alpine, the BSDs, macOS, containers | Only where systemd is the service manager |
Sources: systemd.timer(5), systemd.time(7), systemd.service(5), systemd.exec(5), systemd.unit(5), loginctl(1), cronie crontab(5), Debian crontab(5). What is a cron job sources each of cron's gaps.
Version numbers mark when systemd added an option. RHEL 9 ships systemd 252, Ubuntu 24.04 ships 255, Debian 13 and RHEL 10 ship 257, and Ubuntu 26.04 ships 259 (Debian, Ubuntu, CentOS Stream). So RHEL 9 and Ubuntu 24.04 lack DeferReactivation=, and of these only Ubuntu 26.04 has RandomizedOffsetSec=.
The overlap row cuts both ways. A timer waits for the service it started to stop before scheduling again (timer.c), and a oneshot service has no time limit by default, so one hung run blocks every later run. Give every timer-driven service a TimeoutStartSec=. Catch-up has a cost too: with Persistent=true, a 02:30 backup missed during an outage runs as soon as the machine is back, perhaps mid-morning.
When Cron Is Still the Better Choice#
- The job is one line and needs nothing from the table. The timer version is two files, a reload, and an enable. Running both schedulers on one host also splits the answer to "what runs at 02:00?" across two places.
- The container doesn't run systemd. Most images start one process as PID 1 with no service manager to read a timer. Run a cron daemon in the container, or use the platform's scheduler, such as a Kubernetes CronJob.
- The job must also run where systemd doesn't. Alpine uses OpenRC (Alpine wiki) with BusyBox crond, the BSDs have their own init and cron, and macOS still runs cron, which Apple deprecated in favor of launchd (Apple). The crontab format is in POSIX.1-2024, so one entry works across all of them, within each implementation's quirks.
- Your team and tooling already handle crontab well. Migrate a job when it needs a row from the table, not on principle.
Convert a Crontab Line: A Worked Example#
This entry in appuser's crontab on a Debian 13 server sets two variables, changes directory, uses shell syntax, escapes a %, and relies on mail to report trouble:
[email protected]
BACKUP_BUCKET=s3://example-backups/app
30 2 * * * cd /srv/app && ./bin/backup --to "$BACKUP_BUCKET/db-$(date +\%F).dump"It becomes a service that describes the run and a timer that schedules it. Give both the same name: a timer starts the service named after it unless Unit= says otherwise.
The Service#
# /etc/systemd/system/nightly-backup.service
[Unit]
Description=Nightly database backup
Wants=network-online.target
After=network-online.target
OnFailure=notify-failure@%n.service
[Service]
Type=oneshot
User=appuser
WorkingDirectory=/srv/app
Environment=BACKUP_BUCKET=s3://example-backups/app
ExecStart=/bin/sh -c 'exec ./bin/backup --to "$$BACKUP_BUCKET/db-$$(date +%%F).dump"'
TimeoutStartSec=2hUser=stands in for the crontab's owner; without it, the job runs as root.WorkingDirectory=replaces thecd.Environment=replaces the variable line. Keep secrets out of it: systemd.exec(5) warns that unit environment variables are exposed to unprivileged clients over D-Bus and recommendsLoadCredential=.ExecStart=isn't run by a shell, so&&, pipes, and$(...)need an explicit/bin/sh -c. systemd expands$and%before the shell sees the line, so a dollar sign meant for the shell is written$$and a percent sign%%; cron's\%becomes%%(systemd.service(5)). A script file avoids both rules.TimeoutStartSec=stops a hung run after two hours and marks it failed.Wants=andAfter=network-online.targethold a catch-up run at boot until the network is up (systemd.special(7)).OnFailure=replacesMAILTO, as described below.
The Timer#
# /etc/systemd/system/nightly-backup.timer
[Unit]
Description=Start nightly-backup.service at 02:30
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.targetWith no zone in the expression, OnCalendar= uses the system time zone, as cron does. The run starts within a minute after 02:30, at a host-specific but stable point; add AccuracySec=1s if the exact minute matters.
Translate Crontab Schedules to OnCalendar#
Cron expression syntax covers the cron side. On the systemd side, systemd-analyze calendar (systemd 236+) normalizes an expression and prints when it fires next, and --iterations= (242+) lists several runs:
systemd-analyze calendar --iterations=5 'Mon..Fri 09..17:00'These translations follow the rules and examples in systemd.time(7). Run the command on your host before switching; it also shows the zone the timer will use.
| Crontab | OnCalendar= |
Note |
|---|---|---|
*/5 * * * * |
*:0/5 |
Omitted seconds mean :00 |
0 */2 * * * |
0/2:00 |
00:00, 02:00, through 22:00 |
0 9-17 * * 1-5 |
Mon..Fri 09..17:00 |
|
@hourly, @daily, @monthly, @yearly |
hourly, daily, monthly, yearly |
Same times as cron |
0 0 * * 0, @weekly |
Sun 00:00 |
systemd's weekly is Monday |
0 0 1 */3 * |
*-01/3-01 00:00 |
Same as quarterly |
CRON_TZ=Asia/Tokyo, then 0 22 * * 1-5 |
Mon..Fri 22:00 Asia/Tokyo |
CRON_TZ is cronie only |
30 4 1,15 * 5 |
Two lines: *-*-01,15 04:30 and Fri 04:30 |
Cron runs when either day field matches |
0 0 */2 * 0 (cronie, Debian) |
Sun *-*-01/2 00:00 |
Sundays with odd dates |
| None; schedule 28-31 and test the date | *-*~01 23:00 |
Last day of every month |
@reboot |
OnBootSec=1min instead of OnCalendar= |
Fires at once if started after that point in the boot |
A timer with several OnCalendar= lines fires whenever any of them comes due.
Cron Matches Either Day Field; systemd Matches Both#
When both day fields are restricted, cron runs the job on dates that match either one, as POSIX requires; cronie (crontab(5)), Debian's cron, and BusyBox crond (crond.c) all do. A systemd calendar event must match in every part: systemd.time(7) reads Thu,Fri 2012-*-1,5 11:12:13 as the 1st or 5th of a month "but only if that day is a Thursday or Friday."
So 0 9 1-7 * 1 in a crontab runs on the 1st through 7th and every Monday, while its literal translation, Mon *-*-01..07 09:00, runs only on the first Monday. If the first Monday was the intent, the cron line has been running on the wrong days and the timer fixes it. If not, use one OnCalendar= line per day field.
cronie and Debian also treat a day field as unrestricted whenever it starts with *. Debian's crontab(5) notes that 0 0 */2 * sun therefore "runs every Sunday that's an uneven date," hence Sun *-*-01/2.
Daylight saving time differs too. cronie and Debian's cron run a job from the skipped spring-forward hour right after the change (cronie, Debian); a calendar timer skips that day (systemd#5595, open as of October 24, 2026). An expression in UTC, such as *-*-* 07:30 UTC, avoids it. Cron and daylight saving time covers both sides.
What Changes in the Job's Environment#
| Cron (cronie, Debian) | systemd system service | |
|---|---|---|
| Command parsing | The whole line goes to /bin/sh |
One program and its arguments; no &&, pipes, or redirection |
% |
Newline, unless written \% |
Specifier prefix; write %% |
$ |
Expanded by the shell | Expanded by systemd first; write $$ |
PATH |
/usr/bin:/bin (Debian), /usr/bin:/bin:/usr/sbin:/sbin (cronie) |
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin |
HOME, LOGNAME, SHELL |
HOME and LOGNAME from passwd; SHELL=/bin/sh |
Set from passwd only when User= is set |
| Working directory | The user's home directory | /, unless WorkingDirectory= is set |
Sources: Debian's pathnames.h, entry.c, and do_command.c; cronie's pathnames.h, entry.c, and security.c; systemd.exec(5).
A job that worked under cron because PATH, HOME, or the working directory happened to suit it can fail on its first timer run. Compare first: sudo systemd-run --pipe --uid=appuser env prints what a service running as appuser receives, a variant of the systemd-run -P env check systemd.exec(5) suggests.
Move Output From Mail to the Journal#
Cron mailed whatever the job printed. A service writes stdout and stderr to the journal by default, read with journalctl -u nightly-backup.service, and systemd records each run's result even when the job prints nothing, so systemctl --failed lists every service whose last run failed. Check that the journal persists: with journald's Storage=auto, logs stay in memory under /run unless /var/log/journal exists (journald.conf(5)). If something still reads the old log file, StandardOutput=append:/var/log/app/backup.log (systemd 240+) writes there instead.
If mail was how failures reached someone, OnFailure= takes over. The service above names a notify-failure@ template with %n, its own full name, as the instance; the systemd timers guide has that handler, which mails the failed unit's status. It still needs a mail command and a working MTA, as cron did, but the trigger is better: cron mailed any output, including chatter from successful runs, and nothing from a job that failed silently, while OnFailure= fires on a non-zero exit or a timeout, whatever the job printed.
Migrate Without a Double Run#
Cron and systemd don't know about each other, and systemd's overlap protection covers only runs it started, so a live crontab entry plus a live timer means two runs. Remove the cron entry before starting the timer, or in the same change; a slip then costs one run, not two copies at once.
-
Find every copy of the entry in user crontabs,
/etc/crontab, and/etc/cron.d, on every host; the crontab guide lists every job on a machine. Remove it from the configuration management that installed it too, or the next run puts the line back. -
Install and test the service alone.
systemd-analyze verifyreports unknown directives and missing executables. Start the service by hand only if a run now is safe;systemctl startwaits for a oneshot service to finish.Shellsudo systemctl daemon-reload systemd-analyze verify /etc/systemd/system/nightly-backup.{service,timer} sudo systemctl start nightly-backup.service systemctl status nightly-backup.service -
Switch outside the job's window, not at 02:25. Comment out the line with
sudo crontab -u appuser -e, leaving a dated note for rollback, then start the timer.--nowmatters:enablealone waits for the next boot. APersistent=truetimer's first start doesn't trigger a catch-up run, because systemd creates its stamp file and waits for the next 02:30 (timer.c).Shellsudo systemctl enable --now nightly-backup.timer systemctl list-timers nightly-backup.timer # NEXT: the coming 02:30 -
Verify the first scheduled run the next morning:
Shellsystemctl list-timers nightly-backup.timer # LAST: just after 02:30 systemctl show nightly-backup.service -p Result -p ExecMainStatus journalctl -u nightly-backup.service --since 02:00 journalctl -u cron --since 02:00 # crond on cronie systemsExpect
Result=success,ExecMainStatus=0, and nothing in cron's log starting the backup. Then check the dump itself. Delete the commented line after a few clean runs. If two runs still appear, cron job running twice covers the other causes.
A script that must keep a cron entry for hosts without systemd can step aside on systemd hosts, as Debian's man-db 2.13.1 does in its daily cron script: if [ -d /run/systemd/system ]; then exit 0; fi. That's safe only if every systemd host has the timer.
Neither scheduler reports the run that never happens. OnFailure= needs the service to start and fail, so it can't flag a timer that was never enabled, a host that's down, or a cutover that removed the cron line but missed the timer. A heartbeat monitor such as CronRadar alerts when an expected check-in doesn't arrive. There is no CronRadar SDK or auto-discovery for systemd timers, so each service reports with its own HTTP check-in; the systemd timers guide has a drop-in that adds one to any service. Add it before the switch, and the monitor covers the cutover.
Frequently Asked Questions#
Are systemd timers better than cron?#
For important jobs on a systemd host, usually: a timer can catch up after downtime, never runs two copies, enforces a time limit, and records every run's output and exit status. Cron is simpler for one-line jobs and is the one that works in most containers and on systems without systemd. Neither tells you when a job stops running.
Can systemd timers replace cron completely?#
On a systemd host, yes. Arch Linux installs no cron daemon by default because its base system uses timers (Arch wiki). Per-user jobs need lingering to run while the user is logged out. To keep crontab syntax, systemd-cron 2.8.2 translates crontabs into timer and service units with a generator and ships its own crontab command; Debian's cron package conflicts with it.
How do I convert a cron job to a systemd timer?#
Write a Type=oneshot service that runs the command with the User=, WorkingDirectory=, and Environment= cron used to supply, and a timer of the same name whose OnCalendar= matches the schedule. Test with systemd-analyze calendar and systemctl start, then remove the cron entry and systemctl enable --now the timer in one change.
Do systemd timers run missed jobs?#
Only OnCalendar= timers with Persistent=true, and only once: if the timer would have fired at least once while inactive, it fires once when it starts again. A timer's first start doesn't catch up. Suspend is different: a calendar timer that came due while the machine slept fires on resume, even without Persistent=.
Is cron deprecated on Linux?#
No. POSIX.1-2024 still specifies crontab, and Debian gives its cron package Priority: important (control), the level for programs "one would expect to find on any Unix-like system" (Debian Policy). Fedora and RHEL ship cronie (Fedora, CentOS Stream). Arch is the exception, with no cron in its base install; on macOS, Apple deprecated cron in favor of launchd.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.