systemd linux operations

Cron vs Systemd Timers: When to Use Each

by Sinthuyan Arulselvam  · April 20, 2026

Cron has been the default task scheduler on Unix systems for over four decades. It works, it's everywhere, and every sysadmin knows it. But systemd timers have been quietly eating its lunch for the better part of a decade — not because they're trendy, but because they solve real, concrete problems that cron handles badly or not at all. This guide walks through both tools honestly, with enough depth that you can make the right choice for your situation.

Why Systemd Timers Exist

Cron was designed in a different era. It assumes your machine is always on, your jobs are self-contained, and you don't need to know what happened after a job ran. In 2025, none of those assumptions hold universally.

Systemd timers were built to address four specific failure modes of cron:

  • Opaque logging. When a cron job fails, the output goes to local mail (which nobody reads) or gets silently discarded. Systemd routes all job output through the journal, making it queryable with journalctl -u yourjob.service.
  • Missed jobs on downtime. If your machine is off when a cron job was supposed to run, cron skips it with no record. Systemd timers with Persistent=true catch up on missed runs at boot.
  • No dependency awareness. Cron runs at wall-clock time regardless of system state. A backup job that fires before the network is up silently fails. Systemd timers participate in the dependency graph.
  • Thundering herd. When dozens of servers all run the same cron job at */5 * * * *, they all hit your database simultaneously. Systemd has RandomizedDelaySec built in.

These aren't theoretical concerns. They're the class of bugs that wake people up at 3am.

How Systemd Timers Work

A systemd timer is a pair of unit files: a .service that defines what to run, and a .timer that defines when to run it. They must share the same base name — backup.service and backup.timer. The timer activates the service; the service does the work.

Here's a minimal example. The service unit:

text
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly database backup
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup.sh
StandardOutput=journal
StandardError=journal

And the matching timer unit:

text
# /etc/systemd/system/backup.timer
[Unit]
Description=Run backup nightly at 02:30
Wants=network-online.target

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

[Install]
WantedBy=timers.target

To activate it:

bash
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer

Note that you enable and start the timer, not the service. The service runs when the timer fires. You can also trigger the service manually at any time with systemctl start backup.service for testing — it runs with the exact same environment and constraints as the scheduled run.

OnCalendar Syntax

This is the single biggest stumbling block when migrating from cron. OnCalendar does not use cron syntax. It uses a calendar event specification that's closer to ISO 8601, and it's significantly more expressive once you learn it.

The basic format is: DayOfWeek Year-Month-Day Hour:Minute:Second

Common patterns:

text
# Every day at midnight
OnCalendar=daily

# Every hour on the hour
OnCalendar=hourly

# Every Monday at 09:00
OnCalendar=Mon *-*-* 09:00:00

# Every weekday at 08:30
OnCalendar=Mon..Fri *-*-* 08:30:00

# First day of every month at 00:00
OnCalendar=*-*-01 00:00:00

# Every 15 minutes
OnCalendar=*:0/15

# Every 5 minutes, sub-second precision
OnCalendar=*:0/5:30

The shortcuts daily, hourly, weekly, monthly, and annually are just aliases for their obvious full forms. You can verify any expression before deploying with systemd-analyze calendar "Mon..Fri *-*-* 08:30:00", which shows you the next several trigger times. Cron has no equivalent of this — you either trust your syntax or you don't.

Sub-second precision is legitimate: OnCalendar=*-*-* 12:00:00.5 fires at half past noon, to the half-second. Nobody needs this often, but it exists.

Persistent=true — Catching Missed Runs

This is one of the most underappreciated features in systemd timers. Set Persistent=true in the [Timer] section and systemd records the last time the timer ran. If the machine was off during a scheduled window, it runs the service immediately at boot to compensate.

text
[Timer]
OnCalendar=daily
Persistent=true

Contrast this with cron's behavior: if your machine is powered off at midnight when the daily backup should run, cron simply skips it. There's no record that it was missed, no catch-up run, and no alert. You find out when you need the backup and it's two weeks old.

The persistence mechanism uses a timestamp file in /var/lib/systemd/timers/ — one per timer unit — recording the last successful activation. On boot, systemd checks these against the OnCalendar schedule and fires any that are overdue.

This makes systemd timers appropriate for any job where eventually consistent execution matters more than exact timing. Nightly reports, certificate renewals, cache warming, database vacuuming — these all benefit from Persistent=true.

RandomizedDelaySec — Built-In Jitter

Anyone who's managed infrastructure at scale has dealt with the thundering herd problem: 50 servers all running 0 2 * * * in cron, all hitting the same database at 02:00:00.000. The load spike is predictable and painful.

Jenkins solved this with the H hash symbol — H 2 * * * — which schedules the job at a consistent but node-specific offset within the given window. Systemd's approach is simpler and doesn't require a CI server:

text
[Timer]
OnCalendar=daily
RandomizedDelaySec=1800

This delays activation by a random duration between 0 and 1800 seconds (30 minutes) after the scheduled time. Each run gets a fresh random value. The distribution is uniform, which means across a fleet of machines, load is spread smoothly across the window rather than spiking at the boundary.

For most use cases, setting RandomizedDelaySec to 10–20% of your job interval is a good heuristic. Hourly jobs get a 5–10 minute jitter. Daily jobs get 1–3 hours.

Cron has no native equivalent. The common workaround — prefixing the command with sleep $((RANDOM % 300)) — works, but it's a hack. It doesn't survive being killed, the delay isn't logged, and it clutters your crontab.

Dependency Management

Systemd timers are first-class citizens in the systemd unit dependency graph. This means you can express ordering and requirements that cron cannot model at all.

text
[Unit]
Description=Sync data to remote
After=network-online.target
Wants=network-online.target

After=network-online.target ensures the service doesn't start until the network stack reports it's fully up — not just that networking daemons have started, but that an actual network connection is available. This is critical for jobs that make outbound connections.

You can also express dependencies on other services:

text
[Unit]
Description=Generate daily report
After=postgresql.service
Requires=postgresql.service

With Requires=, if PostgreSQL isn't running when the timer fires, the service fails immediately with a clean error in the journal, rather than running and failing with a cryptic connection refused error halfway through. The distinction matters for debugging.

Common dependency targets for scheduled jobs:

  • network-online.target — network connectivity confirmed
  • local-fs.target — local filesystems mounted
  • remote-fs.target — network filesystems mounted (NFS, CIFS)
  • multi-user.target — full multi-user system ready

Sandboxing and Security

This is where systemd timers have a categorical advantage over cron. The [Service] section supports a rich set of security directives that let you run jobs with the minimum privileges required:

text
[Service]
Type=oneshot
ExecStart=/usr/local/bin/process-data.sh

# Run as a dynamically allocated, transient user
DynamicUser=yes

# Private /tmp — isolated from rest of system
PrivateTmp=true

# Mount / and /usr read-only
ProtectSystem=strict

# Cannot gain new privileges via setuid/setgid
NoNewPrivileges=true

# No access to home directories
ProtectHome=true

# Restrict system calls to a safe subset
SystemCallFilter=@system-service

# Allow writes only to these paths
ReadWritePaths=/var/lib/myapp

DynamicUser=yes is particularly powerful. It allocates a unique, unprivileged user ID for the lifetime of the service run and deallocates it when the job finishes. The user has no persistent files, no shell, and no login. There's no /etc/passwd entry to maintain.

Cron runs jobs as the user who owns the crontab — full stop. There's no mechanism for fine-grained privilege restriction within cron itself. You'd need to wrap every job in a custom script with sudo, setuid, or a separate wrapper, and maintain that separately. Systemd makes hardening the default, not an afterthought.

Migrating a Cron Job to Systemd

Here's a concrete walkthrough. Suppose you have this in root's crontab:

bash
30 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1

Step 1: Create the service unit.

text
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly database backup
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=backup
Group=backup
ExecStart=/usr/local/bin/backup.sh
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true

Step 2: Create the timer unit.

text
# /etc/systemd/system/backup.timer
[Unit]
Description=Nightly backup at 02:30

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

[Install]
WantedBy=timers.target

Step 3: Reload, enable, and verify.

bash
# Pick up the new unit files
sudo systemctl daemon-reload

# Enable the timer to start on boot and start it now
sudo systemctl enable --now backup.timer

# Verify it's active and check next trigger
systemctl status backup.timer
systemctl list-timers backup.timer

Step 4: Test the service directly.

bash
sudo systemctl start backup.service
journalctl -u backup.service -n 50

Confirm the job runs correctly and its output appears in the journal. This is the single biggest workflow improvement: you can test the exact scheduled job manually, with the same user, environment, and constraints, without waiting for the timer to fire.

Step 5: Remove the cron entry.

bash
sudo crontab -e
# Delete the backup.sh line and save

Don't leave both active. Double-execution of backup jobs causes data corruption, lock contention, and confusion.

Monitoring with systemctl list-timers

One of cron's persistent frustrations is that crontab -l shows you what's scheduled, but tells you nothing about what actually ran, when it last ran, or whether it succeeded. Systemd gives you real operational visibility:

bash
systemctl list-timers --all

Output columns: NEXT (next trigger time), LEFT (time until next trigger), LAST (last trigger time), PASSED (time since last trigger), UNIT (timer name), ACTIVATES (service it activates).

For job history and output:

bash
# All runs of the backup service, newest first
journalctl -u backup.service --reverse

# Just today's runs
journalctl -u backup.service --since today

# Exit status of last run
systemctl show backup.service --property=ExecMainStatus

There's no cron equivalent of any of this without installing additional tooling like cronic, chronic, or a custom logging wrapper. With systemd, structured logging is the default.

When to Keep Cron

Systemd timers are better in almost every measurable dimension — but cron has genuine advantages in specific contexts that aren't going away.

  • User crontabs without root. Any unprivileged user can run crontab -e and schedule jobs. Systemd user units exist, but they require loginctl enable-linger and are meaningfully more complex to set up. For user-level automation on shared systems, cron is the pragmatic choice.
  • Non-systemd environments. Containers (Docker, LXC without full init), BSD systems (FreeBSD, OpenBSD, macOS), and Alpine Linux with OpenRC don't have systemd. Cron is portable across all of them. If you're writing scripts for distribution across heterogeneous environments, cron-based scheduling is the safer assumption.
  • Legacy systems with cron stability guarantees. Some production systems run RHEL 6-era infrastructure where systemd is present but the operations team has decade-old runbooks built around cron. The risk of migrating may outweigh the benefit. Don't fix what isn't broken if the blast radius of breakage is high.
  • macOS. macOS uses launchd, not systemd. Cron works on macOS (through the compatibility layer), but launchd plist files are the native approach. Neither systemd nor cron is optimal here.

Decision Matrix

Use this table to cut through the noise:

Requirement Cron Systemd Timer
Catch missed runs after downtime No Yes — Persistent=true
Structured, queryable logs No (email or /dev/null) Yes — journal integration
Built-in load spreading / jitter No (manual sleep hack) Yes — RandomizedDelaySec
Network / service dependencies No Yes — After=, Requires=
Security sandboxing No Yes — DynamicUser, PrivateTmp, etc.
Manual test run (same env) Difficult Yes — systemctl start unit.service
Show next/last trigger times No Yes — systemctl list-timers
User-level scheduling (no root) Yes — crontab -e Complex (user units + linger)
Works on macOS / BSD Yes No
Works in containers without init Yes (with crond) No
Sub-second scheduling precision No (1-minute granularity) Yes — OnCalendar sub-second
Operational complexity Low Medium (two files per job)

The heuristic is simple: if you're on a Linux system with systemd and you control the service user, use a systemd timer. The initial overhead of writing two unit files pays back immediately in operational clarity. If you're on macOS, inside a container, on BSD, or need user-level scheduling without root, use cron.

The migration path is always available and always reversible. Start with your highest-stakes scheduled jobs — the ones where a missed run or a silent failure causes real pain — and migrate those first. The rest can follow at your own pace.

Explore related resources

Related Guides