How-to
Automating Reporting WorkflowsDeep dive

Schedule an Excel Report with systemd Timers

Run a Python Excel report on a Linux server with a systemd service and timer instead of cron — a oneshot service with its own working directory and environment file, calendar expressions checked before deployment, catch-up runs after downtime, logs in the journal, failure status you can query and an OnFailure alert hook.

cron has run report jobs for decades, and it still works. On a modern Linux server, systemd timers do the same job with fewer surprises: the job's output lands in the journal with timestamps, its exit status is recorded, missed runs after downtime can be caught up, and schedules such as "the first Monday of the month" are expressed directly. This guide, part of Scheduling Python Excel Scripts with cron, moves a weekday Excel report onto a service and timer. The unit files were checked with systemd-analyze verify, each schedule with systemd-analyze calendar, and the logging behaviour with test runs.

The same weekday report under cron and under systemd A cron line is short but runs with a minimal environment, mails or discards output, and silently skips runs while the machine is off. A systemd timer and service take two small files, but set the working directory and environment explicitly, send output to the journal, record the exit status, can catch up missed runs and can trigger an alert unit on failure. cron one line − minimal, surprising environment − output mailed or lost − runs missed while down are skipped systemd timer explicit directory and environment output and exit status in the journal Persistent=true catches up OnFailure= alert hook

Prerequisites

A Linux machine with systemd, the report in a project folder with its own virtual environment, and the report script exiting non-zero on failure. That last point matters more than any unit setting: systemd judges success by the exit status. A minimal entry point that follows the rule:

Python
import logging
import sys

log = logging.getLogger("sales-report")

def build_report():
    log.info("building weekly sales report")
    # … read inputs, write reports/sales.xlsx …

def main():
    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
    try:
        build_report()
    except Exception:
        log.exception("report failed")
        return 1
    return 0

if __name__ == "__main__":
    sys.exit(main())

Log to standard error without timestamps: the journal adds its own.

Step 1 — Write the service

The service describes how to run the job. Type=oneshot means "run to completion"; the working directory and environment are explicit, so the job behaves the same from a timer as from your shell:

Ini
# /etc/systemd/system/sales-report.service
[Unit]
Description=Weekly sales Excel report
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=reports
WorkingDirectory=/srv/sales-report
EnvironmentFile=/srv/sales-report/report.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/sales-report/.venv/bin/python -m sales_report
TimeoutStartSec=30min

Point ExecStart at the virtual environment's own python — no activation step is needed. EnvironmentFile holds settings and secrets as NAME=value lines; make it readable only by the service user. TimeoutStartSec stops a hung run, such as a network share that never answers, instead of letting it block the next day's run.

PYTHONUNBUFFERED=1 fixes a confusing log: when standard output is not a terminal, Python buffers it until exit. In a test run, a warning printed to standard error appeared in the journal before the "building report" line the script had printed first.

Step 2 — Write the timer

The timer describes when. It has the same name as the service, so it starts it automatically:

Ini
# /etc/systemd/system/sales-report.timer
[Unit]
Description=Run the sales report on weekday mornings

[Timer]
OnCalendar=Mon..Fri 07:30
Persistent=true
RandomizedDelaySec=5min

[Install]
WantedBy=timers.target

Persistent=true makes systemd remember the last run: if the server was down at 07:30, the report runs once shortly after boot instead of being skipped until tomorrow. RandomizedDelaySec spreads jobs that share a schedule, so twenty reports scheduled at 07:30 do not all hit the database in the same second.

A missed run caught up by Persistent=true The report is scheduled for 07:30 on weekdays. On Tuesday the server is down for maintenance from 06:00 to 09:10. Without Persistent the 07:30 run is skipped and the next report arrives on Wednesday. With Persistent=true systemd sees that the scheduled time passed and starts the report right after boot at 09:10. 06:00 server down 07:30 scheduled run missed 09:10 boot → report runs Wed 07:30 normal schedule

Step 3 — Check the schedule before you trust it

Calendar expressions are easy to get subtly wrong. systemd-analyze calendar prints the next run times, so check every expression before deploying it:

Bash
systemd-analyze calendar --iterations=3 "Mon..Fri 07:30"
#   Normalized form: Mon..Fri *-*-* 07:30:00
#       Next elapse: Thu 2026-09-24 07:30:00 CEST
#      Iteration #2: Fri 2026-09-25 07:30:00 CEST
#      Iteration #3: Mon 2026-09-28 07:30:00 CEST

Reporting calendars often need schedules that cron cannot express. In cron, a line with both a day of the month and a weekday runs when either matches, so 0 8 1-7 * 1 runs on every one of the first seven days and on every Monday. systemd combines them with and:

OnCalendar expressions for reporting schedules Mon..Fri 07:30 runs on weekday mornings. Mon *-*-1..7 08:00 runs on the first Monday of each month, which cron cannot express in one line. *-*~01 18:00 runs on the last day of each month, whatever its length. Mon..Fri 07:30 Europe/London runs at 07:30 London time regardless of the server's time zone. Each was checked with systemd-analyze calendar. OnCalendar Next runs checked weekday mornings Mon..Fri 07:30 Thu 24, Fri 25, Mon 28 Sep first Monday Mon *-*-1..7 08:00 5 Oct, 2 Nov, 7 Dec last day of month *-*~01 18:00 30 Sep, 31 Oct, 30 Nov fixed time zone Mon..Fri 07:30 Europe/London 07:30 in London

The time zone suffix matters when the server runs in UTC but readers expect the report at 07:30 local time, including across daylight saving changes.

Step 4 — Enable, test and inspect

Load the units, run the service once by hand to prove it works, then enable the timer:

Bash
sudo systemctl daemon-reload
sudo systemctl start sales-report.service        # one run now, waits for completion
sudo systemctl enable --now sales-report.timer
systemctl list-timers sales-report.timer         # next and last run
journalctl -u sales-report.service --since today
systemctl status sales-report.service            # last result and exit code

A failed run shows in systemctl status as Failed with result 'exit-code' together with the status number, and systemctl --failed lists every failed unit on the machine — a single place to look after a long weekend.

On a machine where you have no root access, the same files go in ~/.config/systemd/user/ and every command takes --user. User timers only run while you are logged in unless an administrator enables lingering for your account with loginctl enable-linger <user>.

Step 5 — Alert on failure

OnFailure= starts another unit whenever the service fails. A small templated alert unit receives the failed unit's name:

Ini
# in sales-report.service, [Unit] section
OnFailure=report-alert@%n.service

# /etc/systemd/system/report-alert@.service
[Unit]
Description=Alert about failed %i

[Service]
Type=oneshot
ExecStart=/srv/sales-report/.venv/bin/python -m report_alert %i

report_alert can read the last lines with journalctl -u <unit> -n 50 --no-pager and send them by email or chat, as in Send an Alert When an Excel Report Job Fails. Because the hook lives in systemd, it also fires when the job is killed for running out of memory or hits its timeout — cases where the script's own exception handler never runs.

Step 6 — Put limits around the job

A report that reads a much larger export than usual can exhaust the server's memory and take other services down with it. A service can carry its own limits, and a few sandboxing settings keep a buggy or compromised script from writing anywhere but its output folder:

Ini
# in sales-report.service, [Service] section
MemoryMax=2G
CPUQuota=100%
ProtectSystem=strict
ReadWritePaths=/srv/sales-report/reports /srv/sales-report/state
PrivateTmp=yes
NoNewPrivileges=yes

MemoryMax is a hard ceiling for the whole unit. In a test run with a 150 MB limit, a script allocating 400 MB was stopped at once, and the journal recorded The kernel OOM killer killed some processes in this unit followed by Failed with result 'oom-kill' — a clear, specific failure that fires OnFailure= like any other, instead of a server grinding into swap. Size the limit from a normal run's peak, which systemctl status and the journal report as "Memory peak" after each run, and leave generous headroom for month-end files. Reduce Memory When Reading Excel with pandas helps when the peak is too high to begin with.

ProtectSystem=strict makes the whole file system read-only for the job except the paths listed in ReadWritePaths, so the report can write its output and state and nothing else. PrivateTmp=yes gives it a private /tmp, which also means leftover temporary files disappear with the run. systemd-analyze security sales-report.service scores the unit and lists the settings that would tighten it further.

Common pitfalls

  • Relative paths. Without WorkingDirectory, a service starts in /, and open("config.toml") fails. Set it, or build paths from the script's location.
  • Editing units without reloading. Changes take effect only after systemctl daemon-reload.
  • Enabling the service instead of the timer. enable sales-report.service does nothing useful for a oneshot job without an [Install] section. Enable the .timer.
  • Excel-dependent scripts. A Linux timer cannot drive desktop Excel. Reports that need Excel belong on Windows, covered in Run a Python Excel Script on Windows Task Scheduler.

Conclusion

Put the job in a Type=oneshot service with an explicit working directory, environment file, PYTHONUNBUFFERED=1 and a start timeout, and the schedule in a matching timer with Persistent=true. Check every OnCalendar expression with systemd-analyze calendar, test the service by starting it once, read results with journalctl and systemctl status, and add an OnFailure= unit so failures reach a person.

Frequently asked questions

How do I schedule a Python script with systemd? Create a .service unit with Type=oneshot whose ExecStart runs the virtual environment's python on your script, and a .timer unit with the same name and an OnCalendar schedule. Enable and start the timer with systemctl enable --now name.timer.

How do I check an OnCalendar expression before using it? Run systemd-analyze calendar --iterations=5 "Mon..Fri 07:30". It prints the normalised expression and the next run times, so a schedule mistake shows up before the first missed report.

Does a systemd timer run a job that was missed while the server was off? Only with Persistent=true in the Timer section. systemd then records the last run and, after boot, starts the service once if a scheduled time passed while the machine was down.

Where does the output of a systemd timer job go? To the journal. Read it with journalctl -u name.service (add --user for user units). Set PYTHONUNBUFFERED=1 in the service, or Python's standard output is written at exit, after messages on standard error.

More on scheduling & orchestration

More on scheduling & orchestration →