Cron vs. systemd Timers: Scheduled Tasks Compared
AI generated
$
/etc
Cron · systemd · Linux · Automation
Cron vs. systemd Timers
scheduled tasks compared head to head

Cron has been the standard for scheduled tasks on Linux for decades, but it hits clear limits around dependencies, logging, and error handling. systemd timers offer a far more observable alternative with OnCalendar syntax, journalctl integration, and automatic retry, and can be introduced step by step through a concrete migration.

15 min read crontab · OnCalendar · journalctl · Retry systemd 245+ · Debian · Ubuntu · RHEL

1. Cron and systemd timers at a glance

Cron has been the de facto standard for time-based tasks since the early Unix systems. The crond daemon periodically reads crontab files and starts commands at fixed points in time. This simplicity is both its strength and its weakness: a single-line entry is enough, but anything beyond "start command X at time Y" has to be rebuilt inside the script itself, whether that is logging, error handling, or dependencies on other services.

systemd timers have been part of the init system since systemd 197 and do not replace cron technically, but conceptually. A timer is its own unit file that activates a matching .service unit at defined points in time. As a result, every timer job automatically inherits all of systemd's capabilities: resource limits, dependency management, structured logging, and state tracking via systemctl status. Anyone who already uses systemd for services, which is the case on practically every modern Linux distribution, gets these capabilities for scheduled tasks essentially for free.

The switch is not worth it for every trivial one-liner, but as soon as a job is production critical, needs logging, or depends on other services, the extra configuration file pays off quickly. The following sections cover both approaches in detail and walk through a concrete migration from a classic cron job to a systemd timer unit.

2. Classic crontab syntax and its pitfalls

Crontab syntax consists of five time fields followed by the command to run: minute, hour, day of month, month, day of week. The entry 0 3 * * * means daily at 3:00 AM. User crontabs are edited with crontab -e and live under /var/spool/cron/crontabs/, while system-wide jobs sit in /etc/cron.d/ with an extra user field. This split is a common source of errors, because beginners forget that system cron files need one field more than personal crontabs.

One of the best-known traps is the minimal environment in which cron runs commands. Unlike an interactive shell, cron by default sets only a very restricted PATH variable and does not load .bashrc. A script that runs flawlessly locally in a terminal fails in the cron context because a binary like node or composer cannot be found. That is why explicit path declarations or setting PATH at the top of the crontab belong to the basic setup of any production configuration.


# crontab -e, user-level crontab entries
# Explicit PATH, because cron does not load .bashrc or /etc/profile
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=admin@mironsoft.de

# m h dom mon dow command
0 3 * * *   /usr/local/bin/backup-db.sh >> /var/log/backup-db.log 2>&1
*/15 * * * * /usr/bin/php /var/www/app/bin/cron.php --queue=default
0 2 * * 0   /usr/local/bin/cleanup-logs.sh

# /etc/cron.d/app-jobs, system-wide entry needs an extra user field
0 4 * * *   www-data /usr/local/bin/reindex.sh >> /var/log/reindex.log 2>&1

3. The structural limits of cron

The biggest structural drawback of cron is the complete absence of dependency management. Cron only knows times, not conditions. If a backup job must only run after a database export has finished, that logic has to be rebuilt manually inside the script, typically with wait loops or lock files. There is no native way to tell cron "start job B once job A has succeeded". There is also no built-in mechanism to prevent a job from starting while the previous instance is still running, which can lead to overlapping processes for long-running tasks.

The second big problem is weak logging. Cron only logs that a job was started, usually as a short line in /var/log/syslog or /var/log/cron. The actual output of the command is lost unless it is explicitly redirected with >> logfile 2>&1, and even then structured metadata such as exit code, runtime, or resource usage is missing. If a job fails, MAILTO is often the only notification path left, which is practically useless in containerized or mail-less environments.

A third, often overlooked problem: cron offers no retry mechanism. If a job fails due to a brief network glitch, cron stubbornly waits until the next scheduled time, whether that is one minute or one week away. For critical maintenance tasks this can mean days of downtime in the worst case, until someone manually notices and fixes the error.

4. systemd timer units: structure and function

A systemd timer always consists of two files: a .timer unit that defines when something is triggered, and a matching .service unit that defines what gets executed. This separation is deliberate because it fully decouples execution logic from time control. The service can be tested manually and independently of the timer with systemctl start backup-db.service, without waiting for the next scheduled time. This speeds up debugging considerably compared to cron, where an error is often only visible at the next scheduled run.

Both units typically live under /etc/systemd/system/ and are reloaded with systemctl daemon-reload after every change. Only the timer is enabled, with systemctl enable --now backup-db.timer, never the service directly, since that would bypass the time control. The current status, including the next scheduled run, can be checked at any time with systemctl list-timers, which provides an overview that cron does not offer in this form.


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

[Service]
Type=oneshot
User=www-data
ExecStart=/usr/local/bin/backup-db.sh
TimeoutStartSec=1800

# /etc/systemd/system/backup-db.timer
[Unit]
Description=Run backup-db.service nightly at 03:00

[Timer]
# Equivalent to cron "0 3 * * *"
OnCalendar=*-*-* 03:00:00
# Catch up missed runs after downtime (e.g. server was off)
Persistent=true
# Spread load across a random window to avoid thundering herd
RandomizedDelaySec=300

[Install]
WantedBy=timers.target

5. OnCalendar syntax in detail

The OnCalendar directive replaces cron's five cryptic fields with a more readable syntax following the pattern weekday year-month-day hour:minute:second, where every part is optional and supports wildcards like *. OnCalendar=*-*-* 03:00:00 is equivalent to the cron expression 0 3 * * *, but is understandable without looking anything up. More complex patterns such as "every first Monday of the month" or "every 15 minutes between 8 AM and 6 PM on weekdays" can be expressed with Mon *-*-1..7 08..18:00/15:00, which would be barely readable in pure cron syntax.

A practical advantage over cron is OnBootSec and OnUnitActiveSec for relative time specifications, such as "10 minutes after boot" or "every 6 hours since the last successful run", independent of fixed clock times. With systemd-analyze calendar "Mon *-*-1..7 08:00:00" any OnCalendar expression can be tested before deployment, showing the actual next execution time, a tool for which cron has no official equivalent.

6. Logging and journalctl integration

The decisive observability advantage of systemd timers lies in the automatic integration with journald. Every output of a service started by a timer, both stdout and stderr, lands in the journal in structured form without any extra configuration, including timestamp, exit code, process ID, and the associated unit. There is no need to append >> logfile 2>&1 to every command anymore, and no log file rotation that an admin has to manage manually, because journald handles retention.

With journalctl -u backup-db.service --since today all runs of a given day can be filtered, and with journalctl -u backup-db.service -f logs can be followed live. Particularly valuable is systemctl status backup-db.service, which shows at a glance whether the last run was successful, how long it took, and which exit code it returned, information that would otherwise have to be laboriously reconstructed from scattered log files with cron. For monitoring integrations, journald can also be forwarded to central log aggregators such as Loki or ELK via ForwardToSyslog or systemd-journal-remote.


# Inspect the last run and its exit code at a glance
systemctl status backup-db.service

# Full log output of the service, newest first
journalctl -u backup-db.service --since today --no-pager

# Follow logs live while the service runs
journalctl -u backup-db.service -f

# List all timers with their next and last trigger time
systemctl list-timers --all

7. Automatic retry and dependencies

Where cron simply waits until the next scheduled time when a job fails, the service unit behind a timer has full access to systemd's restart logic. With Restart=on-failure, RestartSec=30, and StartLimitBurst=3, systemd automatically attempts up to three restarts at 30-second intervals before the job is finally marked as failed. This catches exactly the class of errors that occurs most often in practice: brief network problems, a database container that is not yet ready, or a temporarily locked lock file.

Dependencies are expressed through the standard unit directives Requires, After, and Wants. A job that is only allowed to start once MySQL is reachable gets After=mysql.service and Requires=mysql.service in its service unit, and systemd automatically ensures the correct start order. For chained processing, where job B should only run after job A has succeeded, OnSuccess=job-b.service can be set in job A's service unit, a native feature for which cron users would otherwise need external orchestration tools such as Airflow.


# /etc/systemd/system/backup-db.service (with retry and dependency handling)
[Unit]
Description=Nightly database backup with retry
Requires=mysql.service
After=network-online.target mysql.service
OnFailure=notify-admin@%n.service

[Service]
Type=oneshot
User=www-data
ExecStart=/usr/local/bin/backup-db.sh
Restart=on-failure
RestartSec=30
StartLimitBurst=3
StartLimitIntervalSec=600
TimeoutStartSec=1800

8. Practical migration: from cron job to timer unit

The switch works best job by job, not as one big leap for the entire crontab. As an example, take a database backup that previously ran daily at 3:00 AM via cron. First, the existing cron entry is identified and documented, then the service unit is created that runs exactly the same command the cron job used to run. It is important to test the job in parallel first, before removing the old cron entry, to make sure permissions, environment variables, and working directory have been carried over correctly.

Once both unit files exist, the actual test run follows: systemctl daemon-reload loads the new units, systemctl start backup-db.service runs the service immediately by hand, independent of the timer, and journalctl -u backup-db.service shows right away whether everything worked as expected. Only once this manual test run succeeds reliably is the timer enabled with systemctl enable --now backup-db.timer, and the old cron entry is removed or commented out with crontab -e so the job does not run twice.

For an entire crontab with many entries, a gradual rollout is recommended: migrate uncritical jobs first to build up experience with the new structure, and save critical production jobs for last, once the team is comfortable with systemctl list-timers, journalctl filters, and debugging timer units. An accompanying migration log that lists the old cron line next to the new timer unit makes later troubleshooting considerably easier.


# 1. Document the existing cron entry before touching anything
crontab -l | grep backup-db
# 0 3 * * * /usr/local/bin/backup-db.sh >> /var/log/backup-db.log 2>&1

# 2. Create service + timer unit (see sections above), then reload
sudo systemctl daemon-reload

# 3. Test the service manually, independent of the timer schedule
sudo systemctl start backup-db.service
journalctl -u backup-db.service --since "5 minutes ago" --no-pager

# 4. Only after a successful manual run: enable the timer
sudo systemctl enable --now backup-db.timer
systemctl list-timers backup-db.timer

# 5. Remove the now-redundant cron entry
crontab -e   # comment out or delete the old line

9. Cron and systemd timers head to head

Both approaches have their place, but for production-critical tasks with error handling, logging, and dependencies, the difference is substantial. The following table lays out the key characteristics side by side.

Characteristic Cron systemd Timer
Logging Manual via redirection, unstructured Automatically structured in journald
Retry on failure No mechanism, waits until next slot Restart=on-failure with configurable interval
Dependencies Not available, must be rebuilt in the script After, Requires, OnSuccess natively available
Missed runs after downtime Ignored, no catch-up Persistent=true catches up missed runs
Status inspection No built-in overview systemctl list-timers, systemctl status
Resource limits Not available MemoryMax, CPUQuota via the service unit
Entry barrier One line, instantly understandable Two unit files, more configuration effort

For simple, uncritical maintenance jobs, cron remains a legitimate choice thanks to its simplicity. But as soon as observability, fault tolerance, or dependencies on other services are required, the advantages of systemd timers clearly outweigh the effort, especially since systemd already runs on practically every modern Linux server and no additional software needs to be installed.

Mironsoft

Server automation, monitoring, and deployment infrastructure

Scheduled tasks that run reliably and stay observable?

We analyze existing cron jobs, migrate production-critical tasks to systemd timers, and set up logging and monitoring so failures no longer go unnoticed.

Cron audit

Reviewing existing crontabs for critical jobs and migration needs

Timer migration

Moving cron jobs step by step to systemd timer units with retry logic

Monitoring setup

Connecting journald to central log systems and alerting on failures

10. Summary

Cron remains a pragmatic tool for simple, uncritical tasks: one entry, no extra configuration, instantly understandable. But as soon as logging, error handling, dependencies on other services, or automatic retry are required, cron shows its structural limits clearly. systemd timers solve these problems natively: OnCalendar replaces the cryptic five-field syntax with readable expressions, journald delivers structured logging with no extra effort, and Restart=on-failure automatically catches transient errors without a job having to wait until the next scheduled time.

The migration does not have to happen all at once. A gradual switch, starting with uncritical jobs and a clean test phase per service unit, minimizes risk while building trust and experience with the new structure at the same time. systemd is already available on practically every modern Linux server, so no additional software is needed, just a deliberate decision about which jobs benefit most from better observability.

Cron vs. systemd Timers: the essentials at a glance

Cron limitations

No dependency management, weak logging, no retry on failure. Errors often go unnoticed.

OnCalendar syntax

Readable time expressions instead of cryptic five-field syntax, testable with systemd-analyze calendar.

journalctl integration

Structured logging without manual redirection, instant status overview via systemctl status.

Retry & dependencies

Restart=on-failure, After, and Requires solve problems cron does not even know exist.

11. FAQ: Cron vs. systemd Timers

1Do systemd timers replace cron entirely?
Technically yes, cron still works in parallel. For critical jobs with logging and retry needs, timers are the more robust choice, for trivial tasks cron remains sufficient.
2What does OnCalendar=*-*-* 03:00:00 mean?
Equivalent to cron 0 3 * * *, meaning daily at 3:00 AM. Testable in advance with systemd-analyze calendar.
3How do I see the status of a timer job?
systemctl status name.service shows the last run, exit code, and runtime. journalctl -u name.service --since today provides the full output.
4What does Persistent=true do?
Catches up on a missed run after downtime at the next boot. Cron has no equivalent for this.
5How do I configure automatic retry?
Restart=on-failure, RestartSec for the interval, StartLimitBurst for the maximum attempt count in the service unit.
6Why does my cron job fail even though it works locally?
Usually a restricted PATH variable, since cron does not load shell profiles. Explicit paths and a PATH line in the crontab fix this.
7Can a timer job depend on another service?
Yes, via After and Requires. A job can then only start once another service is running, not natively possible with cron.
8How do I migrate with low risk?
Create units, test the service manually, check journalctl, only enable the timer after success and then remove the cron entry.
9Do I need additional software?
No, systemd timers are part of the init system and already present on practically every modern distribution.
10Is the switch worth it for small jobs?
Not necessarily. For trivial tasks with no logging or dependency needs, a crontab line remains the simplest solution.