systemd timers vs cron: why I finally switched

Cron is fine until a job overlaps, silently fails or misses a run during a reboot. Here's how systemd timers handle those cases and when I still reach for cron.

Alex Novak 5 min read
On this page

For years my rule was simple: if it runs on a schedule, it goes in cron. One line, everyone can read it, done. Then a nightly job started taking longer than its interval, two copies ran at once, and both wrote to the same file. Nobody noticed for a week because cron mailed the errors to a local mailbox that nobody reads.

That was the push I needed to move scheduled jobs on our servers to systemd timers. Here’s what the switch looks like and what I actually gained.

The minimal setup#

A timer is two units: a service that does the work and a timer that decides when to start it.

The service#

/etc/systemd/system/report.serviceini
[Unit]
Description=Nightly report export

[Service]
Type=oneshot
User=reports
ExecStart=/usr/local/bin/export-report --since yesterday

Type=oneshot tells systemd the unit is “active” while the command runs and done when it exits.

The timer#

/etc/systemd/system/report.timerini
[Unit]
Description=Run report export every night

[Timer]
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=10m
Persistent=true

[Install]
WantedBy=timers.target

Then enable it:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now report.timer
systemctl list-timers report.timer

list-timers shows the last run, the next run and how long until it fires. That single command already beats grep CRON /var/log/syslog.

Two lines in that timer are worth explaining:

  • Persistent=true remembers when the timer last fired. If the machine was off at 03:15, the job runs right after boot instead of waiting a day.
  • RandomizedDelaySec=10m spreads the start over ten minutes, which matters when fifty machines would otherwise hit the same database at exactly 03:15.

What I get for free#

No overlapping runs#

If the timer fires while report.service is still running, systemd doesn’t start a second copy, because starting an already active unit does nothing. With cron I used to wrap every job in flock -n /run/lock/report.lock. I still think flock is a great tool, but I no longer need it for this.

Logs in one place#

Everything the job prints to stdout and stderr goes to the journal with timestamps and the exit status:

bash
journalctl -u report.service --since today
systemctl status report.service

To watch a run live, journalctl -fu report.service follows the log; stop it with Ctrl+C.

Failure hooks#

Add this to the [Unit] section of the service:

report.service, [Unit] sectionini
OnFailure=notify-failure@%n.service

A template unit notify-failure@.service can then post to chat or open a ticket, with the failed unit’s name in %i. That replaced the mailbox nobody read.

Resource limits#

Since it’s a regular service, you can set MemoryMax=, CPUQuota=, Nice= or IOSchedulingClass=idle on a heavy job so it can’t starve the main application.

A real environment#

Cron runs with a nearly empty PATH and no idea about your shell profile. A service has a defined environment you can inspect with systemctl show report.service -p Environment, and you can set it explicitly with Environment= or EnvironmentFile=.

Checking calendar expressions#

The OnCalendar syntax is more readable than cron’s five fields, but it’s still easy to get wrong. systemd ships a checker:

console
$ systemd-analyze calendar "Mon..Fri 09:00" --iterations=3
  Original form: Mon..Fri 09:00
Normalized form: Mon..Fri *-*-* 09:00:00
    Next elapse: Mon 2026-07-13 09:00:00 CEST
       (in UTC): Mon 2026-07-13 07:00:00 UTC
       From now: 22h left
           Iter. #2: Tue 2026-07-14 09:00:00 CEST
           Iter. #3: Wed 2026-07-15 09:00:00 CEST

For “every N minutes” schedules, monotonic timers are often a better fit than calendar ones:

report.timer, [Timer] sectionini
[Timer]
OnBootSec=5min
OnUnitActiveSec=15min

This runs five minutes after boot and then fifteen minutes after each previous start, so the schedule drifts with the work instead of being tied to the wall clock.

One-off jobs#

For “run this once in half an hour”, systemd-run creates a transient timer without writing any files:

bash
sudo systemd-run --on-active=30m --unit=reindex /usr/local/bin/reindex-search

It shows up in list-timers and logs to the journal like any other unit, which at never did.

Side by side#

cronsystemd timer
Definitionone line in a crontaba service and a timer unit
Overlapping runsyour problem (flock)prevented by the unit state
Missed runs while offskippedcaught up with Persistent=true
Logsmail or whatever the job doesthe journal, with exit status
Failure alertsMAILTOOnFailure=
Resource limitsnice, by handMemoryMax=, CPUQuota=, …

When I still use cron#

I haven’t deleted crontab from my brain. It’s still the better choice when:

  • the system has no systemd (some containers, minimal images, BSD boxes);
  • a user without root needs a quick personal job and systemctl --user with lingering feels like overkill;
  • the job is genuinely trivial and the team already knows where to look for it.

But for anything that matters in production (backups, exports, cleanups that touch shared data) timers have earned their two extra files. The overlap protection and the journal alone paid for the migration within a month.