How to Run a PyQGIS Script Headless Without Opening QGIS

A script that needs someone to open QGIS and click Run is not automation. The good news is that QGIS was never really a GUI with a scripting hook β€” it is a library with a GUI attached, and the library does not need a screen. The bad news is that Qt, the toolkit underneath, insists on a display connection unless you tell it otherwise, and the error it gives when you forget is one of the least helpful in the whole stack. This guide gets a PyQGIS script running unattended on a server, in Docker, and in CI.

Problem statement

You have working PyQGIS code and you want it to run on a schedule, on a machine with no monitor. The failures come in a predictable order:

  • qt.qpa.plugin: Could not load the Qt platform plugin "xcb" followed by an abort. The script never reaches your first line of GIS work.
  • ModuleNotFoundError: No module named 'qgis' because cron runs a different interpreter from your interactive shell.
  • Algorithm native:buffer not found because the Processing framework is not registered outside the desktop.
  • A window flashes up on a workstation, or the process waits forever for one on a server.
  • Missing CRS or "PROJ: Cannot find proj.db" because the environment variables that the desktop sets for you are not set for your script.
  • A segfault after the work finished β€” the results are on disk, the exit code is 139, and the scheduler reports failure.

The goal: python job.py exits 0, writes its outputs, logs what it did, and never touches a display.

Quick answer

Two things make a PyQGIS script headless: constructing the application with the GUI flag off, and telling Qt to use the offscreen platform.

The initialisation order for a headless PyQGIS script: environment, prefix path, application without GUI, initQgis, Processing, work, exitQgis.
Order matters β€” the environment must be set before Qt is imported, not after.
import os
import sys

os.environ["QT_QPA_PLATFORM"] = "offscreen"     # before any Qt import

from qgis.core import QgsApplication

QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False)                  # False = no GUI
qgs.initQgis()

sys.path.append("/usr/share/qgis/python/plugins")
from processing.core.Processing import Processing
import processing
Processing.initialize()

try:
    processing.run("native:buffer", {
        "INPUT": "data/parcels.gpkg|layername=parcels",
        "DISTANCE": 25, "SEGMENTS": 8, "DISSOLVE": False,
        "OUTPUT": "out/parcels_buffer.gpkg",
    })
finally:
    qgs.exitQgis()

Run it with the QGIS Python. On Linux that is usually just python3; elsewhere see fixing No module named 'qgis'.

Step-by-step solution

Decide where the script will actually run

The three environments need slightly different setup, and choosing early saves a rewrite.

Three headless targets β€” a desktop machine, a bare server, and a container β€” with the setup each one needs.
The script is identical in all three; only how the environment is supplied changes.
  • A workstation that also has QGIS Desktop. Easiest: the libraries are installed and the paths are known. Set QT_QPA_PLATFORM=offscreen so no window appears and the script does not steal focus.
  • A bare Linux server. Install the qgis package from the QGIS repository β€” it pulls in the libraries and the Python bindings without needing a desktop session. Add xvfb only if something genuinely needs a real X server; for qgis.core work, offscreen is enough and much lighter.
  • A container. The official qgis/qgis images ship a full QGIS with Python. Pin a tag β€” qgis/qgis:ltr or a specific version β€” because latest moves and your pinned algorithm parameters may not.

Set the environment before importing anything Qt

QT_QPA_PLATFORM is read when the Qt platform plugin loads, which happens on the first Qt import. Setting it after from qgis.core import … is too late, and this ordering bug is the most common cause of "it works on my machine and not in CI".

Prefer setting it outside Python entirely, so there is no ordering to get wrong:

export QT_QPA_PLATFORM=offscreen
export QGIS_PREFIX_PATH=/usr
export PYTHONPATH=/usr/share/qgis/python:/usr/share/qgis/python/plugins
python3 job.py

A tiny wrapper script that exports these and then execs your job keeps the Python file clean and makes the requirements visible to whoever inherits it.

Get the prefix path right

QgsApplication.setPrefixPath wants the directory that contains share/qgis β€” not the binary, not the resources folder.

Platform Typical prefix
Debian/Ubuntu package /usr
Fedora package /usr
macOS /Applications/QGIS.app/Contents/MacOS
Windows (OSGeo4W) C:\OSGeo4W\apps\qgis-ltr
conda-forge env the environment root, e.g. $CONDA_PREFIX

Get it wrong and QGIS starts, but with no CRS database β€” so the script runs until the first reprojection and then fails with a PROJ error that says nothing about prefixes. Verify in one line:

print(QgsApplication.prefixPath(), QgsApplication.showSettings())

Initialise Processing explicitly

The framework is a plugin. Nothing registers it in a standalone interpreter:

import sys
sys.path.append("/usr/share/qgis/python/plugins")
from processing.core.Processing import Processing
Processing.initialize()

After this, native:, gdal:, and qgis: algorithms resolve. Third-party providers β€” GRASS, SAGA β€” need their packages installed and are worth avoiding in scheduled jobs precisely because they add a deployment dependency. Running Processing algorithms from Python covers what becomes available and how to check.

Always exit cleanly

qgs = QgsApplication([], False)
qgs.initQgis()
try:
    main()
finally:
    qgs.exitQgis()

Without exitQgis(), the process frequently segfaults on teardown after completing its work. The files are correct, the exit code is not, and every scheduler treats it as a failure. This is the single most common reason a headless QGIS job looks flaky.

Give the job an exit code and a log

A scheduled job communicates through two channels only: its exit status and its output.

import logging, sys

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)-7s %(message)s",
    handlers=[logging.FileHandler("logs/qgis-job.log"), logging.StreamHandler()],
)
log = logging.getLogger("job")

try:
    main()
except Exception:
    log.exception("job failed")
    sys.exit(1)

Then route QGIS's own messages into the same log so warnings from deep in the C++ are not lost:

from qgis.core import Qgis, QgsApplication

QgsApplication.messageLog().messageReceived.connect(
    lambda msg, tag, level: log.warning("[%s] %s", tag, msg) if level >= Qgis.Warning else None
)

Test it the way it will run

The final step is the one people skip. Run it from a shell with a stripped environment, exactly as cron will:

env -i HOME="$HOME" PATH=/usr/bin:/bin bash -lc 'cd /srv/gis && ./run-job.sh'

If that works, the crontab entry will work. If it fails, you have found the missing variable now rather than at 02:00. The same discipline applies to any scheduled Python GIS work β€” see scheduling a Python GIS script.

Code examples

Example 1: The wrapper script

#!/usr/bin/env bash
# run-job.sh β€” headless QGIS entry point
set -euo pipefail

export QT_QPA_PLATFORM=offscreen
export QGIS_PREFIX_PATH=/usr
export PYTHONPATH="/usr/share/qgis/python:/usr/share/qgis/python/plugins:${PYTHONPATH:-}"
export PROJ_LIB=/usr/share/proj
export GDAL_DATA=/usr/share/gdal
export QGIS_DEBUG=0

cd "$(dirname "$0")"
exec /usr/bin/python3 job.py "$@"

set -euo pipefail means a failure anywhere stops the script, and exec replaces the shell so signals reach Python directly β€” which matters when a scheduler sends SIGTERM to stop a long run.

Example 2: A context manager you can reuse everywhere

"""qgis_headless.py β€” start and stop a headless QGIS runtime."""
import os
import sys
from contextlib import contextmanager

os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

PREFIX = os.environ.get("QGIS_PREFIX_PATH", "/usr")
PLUGINS = os.environ.get("QGIS_PLUGIN_PATH", "/usr/share/qgis/python/plugins")

@contextmanager
def qgis_headless(processing=True):
    from qgis.core import QgsApplication

    QgsApplication.setPrefixPath(PREFIX, True)
    app = QgsApplication([], False)
    app.initQgis()
    try:
        if processing:
            if PLUGINS not in sys.path:
                sys.path.append(PLUGINS)
            from processing.core.Processing import Processing
            Processing.initialize()
        yield app
    finally:
        app.exitQgis()

Note os.environ.setdefault at module import time, above the Qt import β€” that ordering is the whole point.

Example 3: A Dockerfile that runs the job

FROM qgis/qgis:ltr

ENV QT_QPA_PLATFORM=offscreen \
    QGIS_PREFIX_PATH=/usr \
    PYTHONUNBUFFERED=1

WORKDIR /srv/job
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
COPY . .

ENTRYPOINT ["python3", "job.py"]

PYTHONUNBUFFERED=1 matters more than it looks: without it a container that is killed mid-run loses its log entirely, because the buffer never flushed. Pin the base image tag rather than using latest, for the same reason you pin any dependency.

Example 4: Checking the environment before doing work

Fail in the first second with a clear message rather than in twenty minutes with a cryptic one.

import os
from pathlib import Path

def preflight():
    problems = []

    try:
        from qgis.core import Qgis
    except ImportError as exc:
        raise SystemExit(f"QGIS bindings not importable: {exc}")

    if os.environ.get("QT_QPA_PLATFORM") != "offscreen":
        problems.append("QT_QPA_PLATFORM is not 'offscreen' β€” a display may be required")

    prefix = Path(os.environ.get("QGIS_PREFIX_PATH", "/usr"))
    if not (prefix / "share" / "qgis").is_dir():
        problems.append(f"prefix path looks wrong: {prefix}/share/qgis does not exist")

    for name in ("data/parcels.gpkg", "data/district.geojson"):
        if not Path(name).exists():
            problems.append(f"missing input: {name}")

    if problems:
        raise SystemExit("preflight failed:\n  - " + "\n  - ".join(problems))

    print("QGIS", Qgis.QGIS_VERSION, "β€” preflight OK")

Example 5: A complete scheduled job

#!/usr/bin/env python3
"""Nightly catchment refresh β€” headless."""
import logging
import sys
from pathlib import Path

from qgis_headless import qgis_headless

log = logging.getLogger("catchments")
OUT = Path("out")

def main():
    import processing
    from qgis.core import QgsVectorLayer

    depots = QgsVectorLayer("data/depots.gpkg|layername=depots", "depots", "ogr")
    if not depots.isValid():
        raise RuntimeError("depots layer failed to load")

    log.info("loaded %d depots (%s)", depots.featureCount(), depots.crs().authid())

    buffered = processing.run("native:buffer", {
        "INPUT": depots, "DISTANCE": 500, "SEGMENTS": 16,
        "DISSOLVE": True, "OUTPUT": "TEMPORARY_OUTPUT",
    })["OUTPUT"]

    OUT.mkdir(exist_ok=True)
    out = processing.run("native:clip", {
        "INPUT": buffered, "OVERLAY": "data/district.geojson",
        "OUTPUT": str(OUT / "catchments.gpkg"),
    })["OUTPUT"]

    log.info("wrote %s", out)

if __name__ == "__main__":
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)-7s %(message)s",
        handlers=[logging.FileHandler("logs/catchments.log"), logging.StreamHandler()],
    )
    with qgis_headless():
        try:
            main()
        except Exception:
            log.exception("job failed")
            sys.exit(1)
    log.info("done")

Explanation

Common headless QGIS errors matched to their fixes β€” Qt platform plugin, missing module, algorithm not found, proj.db, segfault on exit.
Five errors cover almost every failed headless run, and each has one fix.

The reason headless QGIS feels harder than it is comes down to a layering accident. qgis.core genuinely does not need a display β€” it is data access, geometry, projections, and algorithms. But it links against Qt for its object system, its string types, and its signal/slot machinery, and Qt's default platform plugin on Linux is xcb, which does need a display. So the library fails at load time for a reason that has nothing to do with what it is about to do. QT_QPA_PLATFORM=offscreen swaps in a platform plugin that draws to memory instead, and everything above it is unchanged.

That also explains why xvfb-run works and why it is usually unnecessary. Xvfb starts a real X server that paints into memory; offscreen skips the X server entirely. If your work involves genuine rendering β€” a print layout, a map image β€” offscreen is still normally sufficient, because QGIS renders through Qt's paint engine, not through X primitives. Reach for Xvfb only if you hit a specific renderer that refuses, and expect to pay for a whole X server process to do it.

The second structural point is the difference between the desktop's environment and yours. When you launch QGIS Desktop, a launcher script sets PROJ_LIB, GDAL_DATA, the Qt plugin path, and the Python path before starting the application. Your standalone script gets none of that. On a well-packaged Linux install the system defaults happen to be right, which is why Linux feels easy and Windows and macOS feel hard β€” on those platforms QGIS ships its own private GDAL and PROJ, and finding them is the whole job. python-qgis.bat on Windows and the bundled interpreter on macOS exist precisely to set those variables for you, which is why the advice is always to use them rather than a system Python.

Third: the segfault-on-exit problem is not a bug you can code around by ignoring it. QGIS holds C++ objects whose lifetime is managed by reference counting on both sides of the binding. When the interpreter shuts down while providers are still registered, destructors run in an order nobody designed. exitQgis() performs an orderly teardown, and calling it in a finally block means it happens even when your job raises. If you still see a crash after that, the usual culprit is a QgsVectorLayer created in a function and referenced only by C++ β€” keep a Python reference, or hand ownership to QgsProject.instance().

Once the script runs cleanly, everything else about operating it is ordinary Python automation: scheduling, retries, alerting, and reproducibility all apply without modification, because from the scheduler's point of view this is just a program that exits 0 or 1.

Edge cases or notes

offscreen versus minimal versus Xvfb

QT_QPA_PLATFORM=offscreen is the right default. minimal exists for tests and lacks some paint support. xvfb-run -a python3 job.py is the fallback when a specific renderer or plugin insists on a real X connection β€” correct, but heavier, and it introduces a second process that can fail independently.

cron has almost no environment

cron runs with a minimal PATH, no DISPLAY, and none of your shell profile. Always call a wrapper script that exports what is needed and uses absolute paths, and always redirect output somewhere durable. A cron job that works interactively and fails on schedule is nearly always an environment problem, not a QGIS one.

Fonts and rendering on a minimal server

If the job exports map images or PDFs, a container with no fonts installed produces output with missing labels rather than an error. Install a font package (fonts-dejavu is a safe minimum) in any image that will render β€” see exporting map layouts from PyQGIS.

Locale affects number parsing

A server set to a locale using commas as decimal separators can change how some algorithms read numeric strings. Set LC_ALL=C.UTF-8 in the wrapper script to remove the variable entirely.

The QGIS user profile

QGIS keeps settings, installed plugins, and saved Processing models in a user profile directory. A headless run under a different user account has a different (usually empty) profile β€” which is why a saved Processing model that works for you is "not found" for the service account. Either place models in a path you load explicitly, or set QGIS_CUSTOM_CONFIG_PATH to a profile directory you control and check into version control.

Memory and long runs

A headless QGIS process holds providers and caches for its lifetime. For a long batch, prefer restarting the process per chunk over letting one process grind through thousands of layers β€” the pattern in building a resumable batch job makes that cheap, because a restart resumes rather than starts over.

FAQ

Do I need Xvfb to run PyQGIS on a server?

Usually not. QT_QPA_PLATFORM=offscreen gives Qt a platform plugin that renders into memory, which is enough for qgis.core work and for most layout and image exports. Xvfb starts an actual X server and is the fallback for the rare component that insists on a real display β€” worth trying only after offscreen demonstrably fails.

Why does my script work in the QGIS Python Console but not from the terminal?

The console runs inside an already-initialised application: QgsApplication is running, Processing is registered, and every environment variable the launcher sets is in place. From the terminal you must do all three yourself β€” set the environment, construct and initialise the application, and initialise Processing β€” and use the interpreter that can import qgis in the first place.

What exactly does QgsApplication([], False) do?

The first argument is the argv list Qt would parse; an empty list is fine. The second is GUIenabled. Passing False builds an application object with no GUI subsystem, which is what you want for automation. It does not by itself prevent Qt from needing a platform plugin β€” that is what QT_QPA_PLATFORM=offscreen is for, and you generally want both.

My job writes correct output but exits with code 139. Why?

That is a segmentation fault during interpreter shutdown, almost always because exitQgis() was never called or because a layer was garbage-collected while C++ still held it. Put exitQgis() in a finally block, keep Python references to layers for as long as algorithms use them, and the exit code becomes 0.

Can I run this in GitHub Actions or GitLab CI?

Yes. Use the qgis/qgis Docker image as the job container, set QT_QPA_PLATFORM=offscreen, and run your script as a normal step. Pin the image tag; a floating latest will eventually change a QGIS version under you and break a parameter name or an algorithm id at the worst time.

How do I use my saved Processing models in a headless run?

Models live in the QGIS user profile, and a service account has a different profile from yours. Either copy the .model3 files into a directory the job owns and load them explicitly with QgsProcessingModelAlgorithm.fromFile(), or set QGIS_CUSTOM_CONFIG_PATH to a profile directory that you version-control alongside the script.

Does headless QGIS use less memory than the desktop?

Somewhat β€” there is no canvas, no map cache, and no widget tree β€” but the data-side cost is identical, because the providers, the geometry engine, and the algorithm working sets are the same. Size the machine for the data, not for the absence of a window, and chunk very large jobs across process restarts.