# systemd Timers vs Cron: Which to Use and How to Migrate

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/systemd-timers-vs-cron

> Compare systemd timers and cron on catch-up, overlap, logging, environment, and portability, then convert a crontab line to units without running it twice.

**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](https://cronradar.com/blog/systemd-timers).

## 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)](https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html), [systemd.time(7)](https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html), [systemd.service(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html), [systemd.exec(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html), [systemd.unit(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html), [loginctl(1)](https://www.freedesktop.org/software/systemd/man/latest/loginctl.html), [cronie crontab(5)](https://man7.org/linux/man-pages/man5/crontab.5.html), [Debian crontab(5)](https://manpages.debian.org/trixie/cron/crontab.5.en.html). [What is a cron job](https://cronradar.com/blog/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](https://packages.debian.org/trixie/systemd), [Ubuntu](https://launchpad.net/ubuntu/+source/systemd), [CentOS Stream](https://gitlab.com/redhat/centos-stream/rpms/systemd)). 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](https://github.com/systemd/systemd/blob/v262.1/src/core/timer.c#L837-L842)), 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](https://wiki.alpinelinux.org/wiki/OpenRC)) with BusyBox crond, the BSDs have their own init and cron, and macOS still runs cron, which Apple deprecated in favor of launchd ([Apple](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html)). The crontab format is in [POSIX.1-2024](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/crontab.html), 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:

```text
MAILTO=ops@example.com
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

```ini
# /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=2h
```

- `User=` stands in for the crontab's owner; without it, the job runs as root. `WorkingDirectory=` replaces the `cd`.
- `Environment=` replaces the variable line. Keep secrets out of it: [systemd.exec(5)](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html#Environment=) warns that unit environment variables are exposed to unprivileged clients over D-Bus and recommends `LoadCredential=`.
- `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)](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html)). A script file avoids both rules.
- `TimeoutStartSec=` stops a hung run after two hours and marks it failed.
- `Wants=` and `After=network-online.target` hold a catch-up run at boot until the network is up ([systemd.special(7)](https://www.freedesktop.org/software/systemd/man/latest/systemd.special.html)).
- `OnFailure=` replaces `MAILTO`, as described below.

### The Timer

```ini
# /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.target
```

With 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](https://cronradar.com/blog/cron-expression) 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:

```bash
systemd-analyze calendar --iterations=5 'Mon..Fri 09..17:00'
```

These translations follow the rules and examples in [systemd.time(7)](https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html). 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](https://pubs.opengroup.org/onlinepubs/9799919799/utilities/crontab.html) requires; cronie ([crontab(5)](https://man7.org/linux/man-pages/man5/crontab.5.html)), Debian's cron, and BusyBox crond ([crond.c](https://github.com/vda-linux/busybox_mirror/blob/1_37_0/miscutils/crond.c#L336-L360)) 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)](https://manpages.debian.org/trixie/cron/crontab.5.en.html) 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](https://man7.org/linux/man-pages/man8/cron.8.html), [Debian](https://manpages.debian.org/trixie/cron/cron.8.en.html)); a calendar timer skips that day ([systemd#5595](https://github.com/systemd/systemd/issues/5595), open as of October 24, 2026). An expression in `UTC`, such as `*-*-* 07:30 UTC`, avoids it. [Cron and daylight saving time](https://cronradar.com/blog/cron-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](https://sources.debian.org/src/cron/3.0pl1-197/pathnames.h/), [entry.c](https://sources.debian.org/src/cron/3.0pl1-197/entry.c/), and [do_command.c](https://sources.debian.org/src/cron/3.0pl1-197/do_command.c/); cronie's [pathnames.h](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/pathnames.h), [entry.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/entry.c), and [security.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/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)](https://www.freedesktop.org/software/systemd/man/latest/journald.conf.html#Storage=)). 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](https://cronradar.com/blog/systemd-timers) 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.

1. **Find every copy of the entry** in user crontabs, `/etc/crontab`, and `/etc/cron.d`, on every host; [the crontab guide](https://cronradar.com/blog/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.
2. **Install and test the service alone.** `systemd-analyze verify` reports unknown directives and missing executables. Start the service by hand only if a run now is safe; `systemctl start` waits for a oneshot service to finish.

   ```bash
   sudo systemctl daemon-reload
   systemd-analyze verify /etc/systemd/system/nightly-backup.{service,timer}
   sudo systemctl start nightly-backup.service
   systemctl status nightly-backup.service
   ```

3. **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. `--now` matters: `enable` alone waits for the next boot. A `Persistent=true` timer'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](https://github.com/systemd/systemd/blob/v262.1/src/core/timer.c#L704-L723)).

   ```bash
   sudo systemctl enable --now nightly-backup.timer
   systemctl list-timers nightly-backup.timer    # NEXT: the coming 02:30
   ```

4. **Verify the first scheduled run** the next morning:

   ```bash
   systemctl 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 systems
   ```

   Expect `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](https://cronradar.com/blog/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](https://salsa.debian.org/debian/man-db/-/blob/debian/2.13.1-1/debian/cron.daily): `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](https://wiki.archlinux.org/title/Cron)). Per-user jobs need lingering to run while the user is logged out. To keep crontab syntax, [systemd-cron](https://github.com/systemd-cron/systemd-cron) 2.8.2 translates crontabs into timer and service units with a [generator](https://github.com/systemd-cron/systemd-cron/blob/v2.8.2/src/man/systemd-crontab-generator.8.in) 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](https://sources.debian.org/src/cron/3.0pl1-197/debian/control/)), the level for programs "one would expect to find on any Unix-like system" ([Debian Policy](https://www.debian.org/doc/debian-policy/ch-archive.html)). Fedora and RHEL ship cronie ([Fedora](https://src.fedoraproject.org/rpms/cronie), [CentOS Stream](https://gitlab.com/redhat/centos-stream/rpms/cronie)). Arch is the exception, with no cron in its base install; on macOS, Apple deprecated cron in favor of launchd.
