Schedule PyQGIS Scripts with cron

Nearly every PyQGIS script that fails under cron fails for the same reason: cron does not run it the way you do. It starts with a nearly empty environment, a different working directory, no display, no PATH beyond a bare minimum, and no interest in what the script prints. The script itself is fine — everything around it is missing.

This recipe belongs to Headless QGIS and Server Automation. It covers the wrapper script that supplies the environment, the crontab entry that calls it, locking so a slow run never overlaps the next one, and the smallest arrangement that makes a failure visible to a person.

What cron takes away, and what the wrapper puts backAn interactive login shell provides a full PATH, PYTHONPATH, display variables, the working directory, locale and profile settings. Cron provides only a minimal PATH, HOME and SHELL. A wrapper script sits between cron and the Python script and restores the QGIS prefix path, the offscreen platform, the profile directory, the locale and an absolute working directory.Your shell was doing more for you than you thoughtyour interactive shellfull PATHPYTHONPATHDISPLAYworking directoryLANG and localeeverything in .profilewhat cron hands youPATH=/usr/bin:/binHOMESHELLno displayhome as working directoryoutput goes nowhere usefulthe wrapper restoresQT_QPA_PLATFORM=offscreenQGIS prefix + profile pathPYTHONPATH for qgis.coreLANG=C.UTF-8an explicit cda log file and a lockNever put this logic in the crontab line itself — it belongs in a file you can run by hand

Prerequisites

  • A PyQGIS script that runs correctly when you invoke it manually, headlessly — see Headless QGIS and Server Automation.
  • A user account that owns the script, the data and the output directory.
  • cron (any Linux distribution) — the same structure applies to systemd timers and Windows Task Scheduler.

Write the wrapper, not a clever crontab line

#!/usr/bin/env bash
set -euo pipefail

export QT_QPA_PLATFORM=offscreen
export XDG_RUNTIME_DIR=/tmp/runtime-gis
export LANG=C.UTF-8
export PYTHONPATH=/usr/share/qgis/python:${PYTHONPATH:-}
export QGIS_CUSTOM_CONFIG_PATH=/srv/gis/profile
export QGIS_AUTH_PASSWORD_FILE=/srv/gis/secrets/master

cd /srv/gis/jobs

exec /usr/bin/python3 nightly_export.py --source /data/readings.gpkg --out /srv/gis/output

Breakdown: set -euo pipefail makes the wrapper stop on the first error rather than carrying on with a broken environment. Every variable is set explicitly because cron inherits almost nothing: PYTHONPATH is what makes import qgis.core work outside the QGIS launcher, and QGIS_CUSTOM_CONFIG_PATH selects the profile whose plugins, saved connections and authentication database the job needs. exec replaces the shell with Python so signals reach the script directly — which matters when a scheduler kills a run. Everything uses absolute paths; the cd exists only for code that writes relative temporary files.

Save it as /srv/gis/jobs/nightly_export.sh, chmod +x it, and — the important step — run it by hand once. A wrapper that has never been executed manually is a wrapper with an untested typo.

The crontab entry

# m  h  dom mon dow  command
  15 2  *   *   *    /srv/gis/jobs/nightly_export.sh >> /var/log/gis/nightly.log 2>&1

Breakdown: 2:15 rather than 2:00 is a small kindness: the top of the hour is when every other job on the machine starts. >> appends so the log accumulates rather than being truncated each night, and 2>&1 captures standard error into the same file — without it, the traceback from a failure goes to cron's mail, which on most servers goes nowhere. One line, one script; anything more complex belongs inside the wrapper where it can be tested.

Stop runs from overlapping

A job that normally takes four minutes will one day take ninety. If it runs hourly, you now have two copies writing the same output.

exec flock -n /var/lock/gis-nightly.lock \
  /usr/bin/python3 nightly_export.py --source /data/readings.gpkg --out /srv/gis/output

Breakdown: flock -n takes an exclusive lock on the file and, because of -n, gives up immediately rather than queueing when another run already holds it. The second invocation exits with status 1 and writes nothing — no duplicate rows, no half-written GeoPackage. The lock is released automatically when the process ends, including when it is killed, because it lives on the file descriptor rather than in the file's contents.

What a lock prevents when a run overrunsTwo timelines cover three hours. Without a lock, the run starting at two o'clock is still going when the three o'clock run starts, and the overlapping region is marked as two processes writing the same output. With a lock, the three o'clock run exits immediately and the four o'clock run proceeds normally once the long run has finished.One slow night is all it takesno lock02:00 run — overruns03:00 run — starts anywayboth writingflock -n02:00 run — holds the lock03:00 skipped04:00 run — normal02:0003:0004:00

Make a failure reach a person

A log file nobody reads is not monitoring. The minimum useful arrangement is a wrapper that notices a non-zero exit and does something about it:

run_job() {
  /usr/bin/python3 nightly_export.py --source "$SOURCE" --out "$OUT"
}

if ! run_job; then
  status=$?
  tail -n 40 /var/log/gis/nightly.log \
    | mail -s "nightly export FAILED (exit $status) on $(hostname)" gis-team@example.org
  exit "$status"
fi

Breakdown: The tail of the log goes into the message, so the recipient sees the traceback rather than being told to go and look for it. Preserving the exit status matters if the wrapper is itself called by something else. The same shape works with any notification channel — a webhook, a systemd OnFailure= unit, a monitoring agent — and the useful discipline is that the absence of a nightly success is also worth alerting on, since a job that never started produces no failure at all. The complementary logging patterns are in Handle Errors and Logging in Unattended Scripts.

The systemd timer equivalent

On a modern Linux server, a timer does everything the crontab line does and several things it cannot. The wrapper script is unchanged; only the declaration differs.

# /etc/systemd/system/gis-nightly.service
[Unit]
Description=Nightly GIS export
After=network-online.target

[Service]
Type=oneshot
User=gis
ExecStart=/srv/gis/jobs/nightly_export.sh
TimeoutStartSec=3600
OnFailure=gis-alert@%n.service
# /etc/systemd/system/gis-nightly.timer
[Unit]
Description=Run the nightly GIS export

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

[Install]
WantedBy=timers.target

Breakdown: After=network-online.target waits for the network, which removes the classic failure of a job starting before a database is reachable after a reboot. TimeoutStartSec kills a run that has hung, converting an indefinite stall into a recorded failure — the guarantee flock alone does not give you. OnFailure= launches a separate alerting unit whenever the job exits non-zero, so notification is configuration rather than code inside the script. Persistent=true runs a missed occurrence once the machine comes back, which matters for a nightly job on a server that is not always on, and RandomizedDelaySec spreads load when several timers share a start time.

Two operational conveniences follow for free: systemctl start gis-nightly.service runs the job by hand exactly as the timer will run it, and journalctl -u gis-nightly gives timestamped, rotated logs with no logrotate configuration. systemd-analyze calendar "*-*-* 02:15:00" prints the next few firing times, which is a better way to check a schedule than waiting to see whether it fires.

What a timer adds over a crontab lineBoth cron and systemd timers schedule a job. Only the timer waits for the network, kills a hung run after a timeout, launches an alerting unit on failure, runs a missed occurrence after downtime, and records timestamped logs in the journal without extra configuration.The wrapper stays the same; the declaration gains guaranteescapabilitycronsystemd timerruns on a scheduleyesyeswaits for the network to be upnoAfter=kills a run that hangsnoTimeoutStartSecalerts on a non-zero exitin the scriptOnFailure=runs a run missed during downtimenoPersistent=true

QGIS version compatibility

The environment variables shown are stable across the whole QGIS 3.x series.

QGIS versionPythonNotes
3.22 LTR3.9QGIS_CUSTOM_CONFIG_PATH and QGIS_AUTH_PASSWORD_FILE behave as shown.
3.28 LTR3.9Identical.
3.34 LTR3.12Baseline for this page; PYTHONPATH is /usr/share/qgis/python on Debian packages.
3.40 / 3.443.12Unchanged. Confirm the Python path after an upgrade, since it follows the packaged interpreter.

Troubleshooting

  • "No module named qgis". PYTHONPATH does not include the QGIS Python directory. Find it with python3 -c "import qgis, os; print(os.path.dirname(qgis.__file__))" in a working shell.
  • The job works by hand and not from cron. Compare environments: add env > /tmp/cron-env.txt as a temporary first line in the wrapper and diff it against your shell's env.
  • It hangs forever. Something wants a display or a password. Set the offscreen platform and provide QGIS_AUTH_PASSWORD_FILE.
  • The log is empty even though the script prints. Python is buffering. Set PYTHONUNBUFFERED=1 in the wrapper.
  • Output lands in the wrong place. A relative path resolved against cron's working directory. Make every path absolute.
  • A percent sign in the crontab line breaks it. cron treats % as a newline; escape it as \%. This bites hardest when a date format is embedded in the command — another argument for keeping the crontab line trivial.

Conclusion

Scheduling a PyQGIS script is mostly about restoring what cron removes. Put the environment in a wrapper script you can run by hand, keep the crontab line to one command plus a redirect, take a lock so a slow run cannot overlap the next, and make a non-zero exit reach a human. The Python is the part that already works.

Frequently Asked Questions

Should I use systemd timers instead? On a modern Linux server, yes — timers give you dependency ordering, resource limits, OnFailure= handlers and journald logging. The wrapper script is identical; only the scheduling declaration changes.

How do I schedule this on Windows? Task Scheduler, calling a .bat wrapper that sets the same variables through the OSGeo4W environment script. Run the task whether or not the user is logged on, and give it a working directory explicitly.

Can cron run a Docker container? Yes, and it is a good pairing: the container fixes the environment, so the wrapper shrinks to a docker run with mounts. See Run PyQGIS in a Docker Container.

How do I stop the log growing without limit? Add a logrotate rule for the file. Rotating daily and keeping fourteen copies is a sensible default for a nightly job.

Where should the wrapper and the job files live? In a directory owned by the job's user and under version control — /srv/gis/jobs in these examples. Keeping the wrapper, the Python script and any SQL together means a change is reviewable and a rollback is a checkout rather than an act of memory.

What if the script needs to run only on weekdays? Use the day-of-week field: 15 2 * * 1-5. Resist encoding business rules such as public holidays there — put them in the script, where they can be tested.