Run a Python Script at Boot on Pi Zero with a systemd Service
Most Pi Zero projects are meant to run unattended: a sensor logger in a corner, a camera on a window, a button box on a desk. That means the Python program must start by itself when power is applied, restart if it crashes, and keep a log you can read later. Running the script from an SSH session is not enough, because it stops the moment you disconnect.
Raspberry Pi OS uses systemd to start everything at boot, from networking to the SSH server. In this tutorial you will write a small Python program that shuts down cleanly, create a service unit for it, manage it with systemctl, read its output with journalctl, and use a systemd timer as a modern replacement for cron.
Why systemd Instead of rc.local or cron @reboot
| Method | Restarts on crash | Waits for network | Logging |
|---|---|---|---|
| rc.local | No | Not reliably | None unless you redirect output |
| cron @reboot | No | No | Mailed or lost |
| ~/.bashrc | No | No | Runs on every login, not at boot |
| systemd service | Yes, with Restart= | Yes, with After= and Wants= | Automatic, through journalctl |
A systemd service is described by a short text file called a unit. systemd reads it, starts the program as the user you choose, captures everything it prints to standard output and standard error, and can restart it after a failure with a delay you set.
A Service-Friendly Python Program
When systemd stops a service it sends the SIGTERM signal and, if the program does not exit within a timeout, kills it. A well-behaved program catches SIGTERM, finishes its current work and exits. The example below logs the CPU temperature of the Zero every 30 seconds and handles SIGTERM properly. Save it as /home/student/temp_logger.py, using your own username.
#!/usr/bin/env python3
"""Log the Pi Zero's CPU temperature; designed to run under systemd."""
import signal
import sys
import time
INTERVAL_S = 30
running = True
def handle_sigterm(signum, frame):
"""systemd sends SIGTERM on 'systemctl stop'; ask the loop to finish."""
global running
running = False
signal.signal(signal.SIGTERM, handle_sigterm)
def cpu_temp_c():
# The kernel reports millidegrees Celsius in this file
with open('/sys/class/thermal/thermal_zone0/temp') as f:
return int(f.read().strip()) / 1000.0
def main():
print('temp_logger started', flush=True)
while running:
print(f'CPU temperature: {cpu_temp_c():.1f} C', flush=True)
# Sleep in short steps so SIGTERM is handled within a second
for _ in range(INTERVAL_S):
if not running:
break
time.sleep(1)
print('temp_logger stopping cleanly', flush=True)
return 0
if __name__ == '__main__':
sys.exit(main())
Note the flush=True on every print. When output goes to a pipe instead of a terminal, Python buffers it, and without flushing your log messages may appear late or be lost when the service stops.
Creating the Service Unit
System-wide units you write yourself belong in /etc/systemd/system/. Create the file with sudo nano /etc/systemd/system/temp-logger.service and paste the following, replacing student with your username.
[Unit]
Description=CPU temperature logger
# Start after the network is up (useful if your script sends data)
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=student
WorkingDirectory=/home/student
# Use absolute paths; for a virtual environment use its python, e.g.
# ExecStart=/home/student/envs/sensors/bin/python /home/student/temp_logger.py
ExecStart=/usr/bin/python3 /home/student/temp_logger.py
# Restart after crashes, waiting 5 s between attempts
Restart=on-failure
RestartSec=5
# Belt and braces: unbuffered Python output
Environment=PYTHONUNBUFFERED=1
[Install]
# Start in normal multi-user boot
WantedBy=multi-user.target
# Tell systemd to re-read unit files after any edit
sudo systemctl daemon-reload
# Enable at boot and start now in one step
sudo systemctl enable --now temp-logger.service
# Is it running? Shows the last few log lines too
systemctl status temp-logger.service
# Follow the log live (Ctrl+C to stop following)
journalctl -u temp-logger.service -f
# Logs since the last boot only
journalctl -u temp-logger.service -b
# Stop, restart, or remove from boot
sudo systemctl stop temp-logger.service
sudo systemctl restart temp-logger.service
sudo systemctl disable temp-logger.service
What Each Directive Does
| Directive | Section | Purpose |
|---|---|---|
| Description= | [Unit] | Human-readable name shown by systemctl status and in the journal |
| Wants= / After= | [Unit] | Pull in network-online.target and start only after it is reached; After= alone only sets order |
| Type=simple | [Service] | The process started by ExecStart is the service itself and keeps running |
| User= | [Service] | Run as an ordinary user instead of root, limiting the damage a bug can do |
| WorkingDirectory= | [Service] | Current directory for the program, so relative file paths behave as expected |
| ExecStart= | [Service] | The command to run; must use absolute paths |
| Restart=on-failure | [Service] | Restart only after a non-zero exit code or a crash, not after a clean stop |
| RestartSec= | [Service] | Delay before restarting, which avoids hammering a sensor that is temporarily missing |
| WantedBy= | [Install] | Which boot target pulls the service in when you run systemctl enable |
systemctl enable does not start anything by itself. It creates a symbolic link under /etc/systemd/system/multi-user.target.wants/, so the service is pulled in at the next boot. Adding --now also starts it immediately. Use Restart=always instead of on-failure if you want the program brought back even after it exits normally, for example a kiosk loop that should never end.
Periodic Jobs with a systemd Timer
Some tasks should run briefly and exit, for example uploading a CSV file every 15 minutes. Instead of a cron entry, pair a oneshot service with a timer. Timers log through the journal and can catch up on a run missed while the Pi was off.
# ---- /etc/systemd/system/upload.service ----
[Unit]
Description=Upload sensor CSV
[Service]
Type=oneshot
User=student
ExecStart=/usr/bin/python3 /home/student/upload.py
# ---- /etc/systemd/system/upload.timer ----
[Unit]
Description=Run upload.service every 15 minutes
[Timer]
OnCalendar=*:0/15
# Run once at boot if a scheduled run was missed while powered off
Persistent=true
[Install]
WantedBy=timers.target
Enable the timer, not the service: sudo systemctl enable --now upload.timer. Then systemctl list-timers shows when it last ran and when it runs next.
Troubleshooting
- status=203/EXEC: the path in
ExecStartis wrong or not absolute. systemd does not use your shell's PATH or~. - ModuleNotFoundError under systemd but not in SSH: the unit uses system Python while you installed packages in a virtual environment. Point
ExecStartat the venv'sbin/python. - Permission denied on GPIO or I2C: the user lacks group membership. Default users are already in
gpioandi2c; a new user must be added withsudo usermod -aG gpio,i2c username. - Service restarts endlessly: read the error with
journalctl -u name -band fix it; systemd eventually stops retrying after repeated quick failures. - Edits seem ignored: run
sudo systemctl daemon-reloadand restart the service after changing a unit file.
Summary
A systemd unit with absolute paths, a dedicated user, Restart=on-failure and WantedBy=multi-user.target makes any Python program start at boot and recover from crashes, while journalctl keeps its output. Catching SIGTERM and flushing output make the program a good citizen under systemd, and timers replace cron for periodic jobs. Every long-running project in this series, from the Flask dashboard to the MQTT node, is deployed this way.