# systemd Timers vs Cron: What We Learned Switching

URL: https://www.devopsness.com/blog/systemd-timers-vs-cron-what-we-learned
Author: Kiril Urbonas
Published: 2026-05-09
Updated: 2026-10-31
Categories: Linux
Tags: Systemd, Cron, Linux, Scheduling, Automation, Ops

Summary: We migrated most scheduled jobs from cron to systemd timers. The wins, the gotchas, and the cases we kept on cron anyway.

---
# systemd Timers vs Cron: What We Learned Switching

Use systemd timers for any scheduled job you need to trust, and keep cron for trivial one-liners. Timers log every run to journald, wait for mounts and services, catch up missed runs with `Persistent=true`, and page you via `OnFailure=`. Cron promises far less: no record of whether a job ran, and no dependency awareness.

> [!VERIFIED]
> systemd 262 (latest man pages; v262.1 released 2026-10-16), against the freedesktop.org systemd.timer(5), systemd.time(7) and systemd.unit(5) man pages and the crontab(5) man page. Checked October 2026.

We migrated most of our scheduled jobs over the past year. This is what we kept, what we abandoned, and where cron is still the right answer.

| | cron | systemd timer |
|---|---|---|
| Logging | Mails output to a local mailbox | journald, with exit codes |
| Missed runs while off | Skipped (needs anacron) | Run at next boot with `Persistent=true` |
| Dependencies | None | `After=`, `RequiresMountsFor=` |
| Failure alerting | None built in | `OnFailure=` starts another unit |
| Start jitter across hosts | None | `RandomizedDelaySec=` |
| Setup cost | One line | Two unit files |

## The incident that started it

Our backup cron job failed silently for three weeks. The script exited non-zero because a mount wasn't ready, cron tried to email the output to a local mailbox nobody reads, and the job kept "running" on schedule while doing nothing. We found out when we needed a restore.

That one incident contained all three of our standing complaints about cron:

**Logging is awkward.** Output goes to local mail by default. We had piped stdout and stderr to log files by hand on every host, and failures still went unnoticed unless someone looked.

**No dependency awareness.** "Run this only after the database and the backup mount are up" isn't a thing in cron. Boot-time jobs racing their dependencies were a recurring source of "why did the 3:01 AM job fail last night?"

**No retry or backoff.** A failed job just fails. It runs again at the next scheduled time, possibly compounding the problem.

## A timer is two units

A systemd timer is a pair: a `.service` that does the work and a `.timer` that says when. The split felt fussy at first; after a couple dozen of them it's habit, and it's what buys the observability.

```ini
# /etc/systemd/system/db-backup.service
[Unit]
Description=Nightly database backup
Wants=network-online.target
After=network-online.target postgresql.service
RequiresMountsFor=/mnt/backups
OnFailure=notify-oncall@%n.service

[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/bin/db-backup.sh
TimeoutStartSec=2h
Nice=10
IOSchedulingClass=idle
```

```ini
# /etc/systemd/system/db-backup.timer
[Unit]
Description=Run db-backup nightly

[Timer]
OnCalendar=*-*-* 02:30:00 UTC
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target
```

Enable it with `systemctl enable --now db-backup.timer`. Give both files the same prefix (`db-backup.service` and `db-backup.timer`); it saves confusion six months later.

`RequiresMountsFor=/mnt/backups` is the line our old cron job lacked: the service won't even attempt to run if the mount is missing, and `After=postgresql.service` holds it until the database is up. If a dependency isn't met, the job fails loudly instead of running against a half-ready system. `OnFailure=` starts another unit when this one fails, which is how a broken backup pages someone instead of emailing the void.

## What we gained

**Logs go to journald.** Same place service logs go, tagged, with exit codes, cross-correlated in time order with everything else on the host. This is the single biggest day-to-day improvement.

```bash
$ systemctl list-timers
$ journalctl -u db-backup.service --since today
Jul 04 02:31:12 host db-backup.sh[4021]: dumped 2.4GB in 41s
Jul 04 02:31:53 host systemd[1]: db-backup.service: Succeeded.
$ systemd-analyze calendar 'Mon..Fri 02:00'   # prints the next elapse time
```

`systemctl list-timers` shows every timer with its last and next run on one screen, which beats parsing crontabs in your head.

**Missed runs are caught up.** `Persistent=true` means that if the machine was off at 2:30, the job runs at next boot instead of being skipped. Cron alone just misses it (anacron exists to patch that gap). For a laptop or a VM that isn't always on, this is decisive.

**No thundering herd.** `RandomizedDelaySec=300` jitters the start by up to five minutes. Run the same cron line on 200 hosts and they all hit the backup target at exactly 02:30:00; timers spread the load. Cron has no equivalent.

**Bounded retries.** The service can use `Restart=on-failure` with `StartLimitIntervalSec` and `StartLimitBurst`, the same retry controls as any other systemd unit.

**Readable schedules.** `OnCalendar=Mon..Fri 02:00` instead of `0 2 * * 1-5`; `*:0/15` for every 15 minutes. `OnBootSec` and `OnUnitActiveSec` add relative scheduling: "5 minutes after boot" or "30 minutes after the previous run".

## The gotchas that bit us

**`Type=oneshot` is mandatory.** Without it, systemd treats the job like a long-running daemon. Backup scripts exit; `oneshot` tells systemd the service runs to completion.

**`OnCalendar` is local time by default.** We append `UTC` to every expression. Otherwise daylight-saving transitions can produce two runs or zero runs on the transition day.

**`Persistent=true` runs once, not once per miss.** A daily job that missed five days runs once when the host returns. Usually what you want; occasionally not.

**No mail by default.** Timers don't email output; you read the journal.

**The calendar syntax is different enough to trip you.** The format is `weekday year-month-day hour:minute:second timezone`. Verbose but unambiguous, and you'll run `systemd-analyze calendar` a few times before it sticks. Run it before every deploy.

## Observability we bolted on

Beyond `journalctl`, three signals per host, all from infrastructure we already ran:

- **Failed unit count.** `systemctl --failed` lists units in failed state; node_exporter scrapes it and we alert on any failed unit on a production host.
- **Last-run time per critical timer.** A nightly backup that hasn't run in two days is a problem. A Prometheus exporter scrapes `systemctl list-timers`.
- **Timer drift.** `Persistent=true` jobs running late or skipping usually means clock skew or a host that was off.

## What we kept on cron, and where timers are wrong

Three categories stayed on cron:

**User-level tasks.** A few admin scripts run as a specific user. User-level timers exist, but explaining "you have to enable lingering for your account" cost more than a user crontab.

**Trivial periodic things.** "Once an hour, curl this endpoint to keep a session alive." A cron line takes five seconds to write; a unit pair takes thirty. If nobody cares when a run is missed, cron is fine.

**Shared servers we don't own.** On machines a partner team controls, their crontab works and we don't push our patterns onto them.

And four cases where timers are the wrong tool entirely: Kubernetes workloads (use `CronJob` resources, which run as Pods), multi-step workflows with conditional logic (Step Functions or Airflow), cross-host coordination like "run on whichever host has capacity" (timers are per-host), and event triggers rather than time triggers (systemd path units, inotify, or a queue).

## How we migrated without a big bang

1. **New scheduled work goes in timers immediately.** No new cron entries on hosts we control.
2. **Existing cron jobs migrate when touched.** Editing a job's command? Convert it in the same change.
3. **Quarterly audit.** A script lists every crontab across hosts; we pick a few to migrate.

After 12 months, about 70% of scheduled work runs on timers. The remaining 30% is mostly the user-level and shared-server cases above, and we probably won't push further.

## Questions people ask

### Do systemd timers run missed jobs like anacron?

Only with `Persistent=true` in the `[Timer]` section, and only for `OnCalendar=` timers. systemd stores the last trigger time on disk, and if the timer would have fired while the machine was off, the service runs immediately when the timer is next activated. It does not replay every missed run: a daily job that missed five days runs once. Without `Persistent=true`, a missed run is simply skipped, as with plain cron.

### How do I check when a systemd timer will run next?

`systemctl list-timers` shows every active timer with its next and last trigger time. To test an expression before deploying it, run `systemd-analyze calendar 'Mon..Fri 02:00'`, which normalizes the expression and prints the next elapse time. Add `--iterations=5` to see several upcoming runs.

## The decision, concretely

- **Would you want to know when the job breaks?** Use a timer: backups, cert renewals, data syncs, and disk cleanup all qualify.
- **Does it depend on a mount, the network, or a database?** Use a timer with `After=` and `RequiresMountsFor=`; cron can't express this.
- **One-line personal task nobody monitors?** Leave it in cron.
- **Runs on Kubernetes or spans hosts?** Neither; use a `CronJob` or a real scheduler.

To check what a cron line really means before you deploy it, the [cron expression generator](/tools/cron-expression-generator) explains it in plain English and lists the next five run times.

## The call we'd make

Make systemd timers the default for new scheduled work on hosts you control, with four settings as muscle memory: `Persistent=true`, a `UTC` suffix, `Type=oneshot`, and journal output, plus `OnFailure=` for anything that matters. That turns "the job silently stopped and we found out during a restore" into "the unit went failed and paged us at 2:31". Don't migrate stable cron jobs en masse; convert them when you touch them. Timers are a modest upgrade that fits naturally if you already run systemd, and the teams that gain most are the ones already working around cron's limits.

## Sources

- [systemd.timer(5) man page: Persistent=, RandomizedDelaySec=, OnCalendar=](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html)
- [systemd.time(7) man page: calendar event syntax](https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html)
- [systemd.unit(5) man page: OnFailure= and RequiresMountsFor=](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html)
- [crontab(5) Linux man page](https://man7.org/linux/man-pages/man5/crontab.5.html)

