How to Run a PyQGIS Script Headless Without Opening QGIS
On this page
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'becausecronruns a different interpreter from your interactive shell.Algorithm native:buffer not foundbecause 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 an environment variable such as
PROJ_DATApoints somewhere stale β or a PostGIS or WMS layer that is simply invalid because the prefix path is wrong. - A segfault after the work finished β the results are on disk, the exit code is 139, and the scheduler reports failure.
Done right, python job.py exits 0, writes its outputs, logs what it did, and never needs 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.
%%writefile job.py
import os
import sys
os.environ["QT_QPA_PLATFORM"] = "offscreen" # before QgsApplication is constructed
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:
result = processing.run("native:buffer", {
"INPUT": "data/parcels.gpkg|layername=parcels",
"DISTANCE": 25, "SEGMENTS": 8, "DISSOLVE": False,
"OUTPUT": "out/parcels_buffer.gpkg",
})
print("wrote", result["OUTPUT"])
finally:
qgs.exitQgis()
Run it with the QGIS Python, in an environment stripped the way cron strips it (run_headless() is defined in The data used on this page):
code, output = run_headless("job.py")
print("exit", code, "|", output[-1])
print(len(gpd.read_file("out/parcels_buffer.gpkg")), "buffered parcels")
exit 0 | wrote out/parcels_buffer.gpkg
21253 buffered parcels
On Linux the QGIS Python is usually just python3 with /usr/share/qgis/python on the path; elsewhere see fixing No module named 'qgis'. Everything on this page was run against QGIS 4.2 installed from the QGIS repository, and against the qgis/qgis:ltr Docker image, which held QGIS 3.44 LTR when this was written.
Environment, prefix path, Processing and a clean exit
Decide where the script will actually run
The three environments need slightly different setup, and choosing early saves a rewrite.
- A workstation that also has QGIS Desktop. Easiest: the libraries are installed and the paths are known. Set
QT_QPA_PLATFORM=offscreenso no window appears and the script does not steal focus. - A bare Linux server. Install the
qgispackage from the QGIS repository β it pulls in the libraries and the Python bindings without needing a desktop session. Addxvfbonly if something genuinely needs a real X server; forqgis.corework,offscreenis enough and much lighter. - A container. The official
qgis/qgisimages ship a full QGIS with Python. Pin a tag βqgis/qgis:ltrmoves when a new LTR is released, and your algorithm parameters may not survive the move.
Set the environment before constructing the application
QT_QPA_PLATFORM is read when Qt loads its platform plugin, which happens when QgsApplication is constructed β not when qgis.core is imported. Without it, on a machine with no display:
Path("no_platform.py").write_text('''
from qgis.core import QgsApplication
qgs = QgsApplication([], False) # no GUI β and still no platform plugin
qgs.initQgis()
print("initialised")
qgs.exitQgis()
''')
Path("late_platform.py").write_text('''
import os
from qgis.core import QgsApplication
os.environ["QT_QPA_PLATFORM"] = "offscreen" # after the import, before the constructor
qgs = QgsApplication([], False)
qgs.initQgis()
print("initialised")
qgs.exitQgis()
''')
for script in ["no_platform.py", "late_platform.py"]:
code, output = run_headless(script, platform=None)
message = next((line for line in output if "Could not load" in line), output[-1])
print(f"{script:18} exit {code}: {message}")
no_platform.py exit 134: qt.qpa.plugin: Could not load the Qt platform plugin "xcb" in "" even though it was found.
late_platform.py exit 0: initialised
The first run aborts with exit 134 β SIGABRT, from Qt β after reporting that it could not load the xcb plugin: the GUI flag alone does not make Qt give up on a display. The second works although the variable was set after the import. The safe rule is simpler than the ordering, though: set it outside Python, in the wrapper that starts the job, so there is no ordering to get wrong:
export QT_QPA_PLATFORM=offscreen
export PYTHONPATH=/usr/share/qgis/python:/usr/share/qgis/python/plugins
python3 job.py
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 still starts, still reads GeoPackages, and still reprojects β which is what makes the mistake hard to see. What it loses is every data provider that ships as a plugin:
Path("providers.py").write_text('''
import sys
from qgis.core import QgsApplication, QgsProviderRegistry, QgsCoordinateReferenceSystem, QgsVectorLayer
QgsApplication.setPrefixPath(sys.argv[1], True)
qgs = QgsApplication([], False)
qgs.initQgis()
providers = QgsProviderRegistry.instance().providerList()
layer = QgsVectorLayer("data/parcels.gpkg|layername=parcels", "p", "ogr")
print(len(providers), "providers | postgres:", "postgres" in providers, "| wms:", "wms" in providers,
"| EPSG:27700 valid:", QgsCoordinateReferenceSystem("EPSG:27700").isValid(), "| GeoPackage layer:", layer.featureCount())
del layer
qgs.exitQgis()
''')
for prefix in ["/usr", "/opt/wrong"]:
print(f"{prefix:11}", run_headless("providers.py", prefix)[1][-1])
/usr 34 providers | postgres: True | wms: True | EPSG:27700 valid: True | GeoPackage layer: 21253
/opt/wrong 17 providers | postgres: False | wms: False | EPSG:27700 valid: True | GeoPackage layer: 21253
With the wrong prefix, half the providers are missing β PostgreSQL, WMS, WFS, delimited text and SpatiaLite among them β so a job that reads PostGIS or a web service fails with a layer that is simply invalid, while everything file-based keeps working. Check it in one line at startup:
print(QgsApplication.prefixPath(), QgsApplication.showSettings())
Initialise Processing explicitly
The framework is a plugin. Nothing registers it in a standalone interpreter, and processing.run() without it fails in a way that reads like a typo:
Path("no_processing.py").write_text('''
import sys
from qgis.core import QgsApplication
qgs = QgsApplication([], False)
qgs.initQgis()
sys.path.append("/usr/share/qgis/python/plugins")
import processing
try:
processing.run("native:buffer", {"INPUT": "data/parcels.gpkg|layername=parcels", "DISTANCE": 25, "OUTPUT": "out/x.gpkg"})
except Exception as err:
print(type(err).__name__, err)
qgs.exitQgis()
''')
print(run_headless("no_processing.py")[1][-1])
QgsProcessingException Error: Algorithm native:buffer not found
After Processing.initialize(), 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 β and let layers go first
qgs = QgsApplication([], False)
qgs.initQgis()
try:
main()
finally:
qgs.exitQgis()
Without exitQgis(), the process can segfault on teardown after completing its work. The files are correct, the exit code is not, and every scheduler treats it as a failure. Measured, for the same buffer job with the finally removed, and for a layer kept alive in a global variable past exitQgis() on the LTR image:
Path("job_no_exit.py").write_text(Path("job.py").read_text().replace(" qgs.exitQgis()", " pass"))
code_without, _ = run_headless("job_no_exit.py")
print("QGIS 4.2, Processing run, no exitQgis():", code_without)
Path("global_layer.py").write_text('''
import sys
from qgis.core import QgsApplication, QgsVectorLayer
QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False)
qgs.initQgis()
layer = QgsVectorLayer("data/parcels.gpkg|layername=parcels", "p", "ogr")
print(layer.featureCount())
if sys.argv[1] == "del":
del layer # release it before tearing QGIS down
qgs.exitQgis()
''')
for mode in ["keep", "del"]:
code, _ = run_headless("global_layer.py", mode, image="qgis/qgis:ltr")
print(f"QGIS 3.44 LTR, layer {mode:4} before exitQgis():", code)
QGIS 4.2, Processing run, no exitQgis(): 139
QGIS 3.44 LTR, layer keep before exitQgis(): 139
QGIS 3.44 LTR, layer del before exitQgis(): 0
Exit code 139 is a segmentation fault. The first case is the missing exitQgis(); the second is subtler: exitQgis() was called, but a layer that is still referenced from Python when the interpreter shuts down is destroyed after QGIS has already been torn down. Deleting the reference β or creating layers inside a function, so they are gone when it returns β fixes it. (QGIS 4.2 survived the global-layer case; the LTR did not, which is reason enough to write code that survives both.)
Give the job an exit code and a log
A scheduled job communicates through two channels only: its exit status and its output. Example 5 below routes Python logging and QGIS's own message log into one file:
QgsApplication.messageLog().messageReceived.connect(
lambda msg, tag, level: log.warning("[%s] %s", tag, msg) if level >= Qgis.Warning else None
)
so warnings from deep in the C++ end up next to the job's own messages instead of being lost.
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'
run_headless() on this page does the same thing: every script here ran with only HOME, PATH and the variables it was given. If that works, the crontab entry will work. The same discipline applies to any scheduled Python GIS work β see scheduling a Python GIS script.
A headless job on Rutland's parcels and villages
The data used on this page
- Parcels β HM Land Registry's INSPIRE Index Polygons for Rutland, 21,253 freehold parcels in British National Grid, as
data/parcels.gpkg. - Villages β the towns, villages and hamlets in Geofabrik's OpenStreetMap extract for Rutland, as
data/villages.gpkg. - District β the Braunston & Martinsthorpe electoral ward (December 2024) from the ONS, as
data/district.geojson.
The helper run_headless() runs a script with the system's QGIS Python (or in a Docker image) with only HOME, PATH and the given variables set, and returns its exit code and output.
import io
import os
import subprocess
import zipfile
from pathlib import Path
import geopandas as gpd
import pyogrio
import requests
pyogrio.set_gdal_config_options({"GML_DOWNLOAD_SCHEMA": "NO"})
for folder in ["data", "out", "logs"]:
Path(folder).mkdir(exist_ok=True)
if not Path("data/parcels.gpkg").exists():
reply = requests.Session().get("https://use-land-property-data.service.gov.uk/datasets/inspire/download/"
"Rutland.zip", timeout=600)
reply.raise_for_status()
zipfile.ZipFile(io.BytesIO(reply.content)).extract("Land_Registry_Cadastral_Parcels.gml")
gpd.read_file("Land_Registry_Cadastral_Parcels.gml").to_file("data/parcels.gpkg", layer="parcels")
if not Path("data/villages.gpkg").exists():
reply = requests.get("https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip",
timeout=300)
reply.raise_for_status()
zipfile.ZipFile(io.BytesIO(reply.content)).extractall("osm")
places = gpd.read_file("osm/gis_osm_places_free_1.shp")
places[places["fclass"].isin(["town", "village", "hamlet"])].to_crs(27700).to_file("data/villages.gpkg", layer="villages")
if not Path("data/district.geojson").exists():
reply = requests.get("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Wards_December_2024_Boundaries_UK_BGC/FeatureServer/0/query",
params={"where": "WD24NM='Braunston & Martinsthorpe'", "outFields": "WD24NM",
"outSR": 27700, "f": "geojson"}, timeout=120)
reply.raise_for_status()
Path("data/district.geojson").write_bytes(reply.content)
def run_headless(script, *args, platform="offscreen", env=None, image=None):
"""Run a script with QGIS's Python in a cron-like environment; return (exit code as a shell shows it, lines)."""
extra = {"QT_QPA_PLATFORM": platform} if platform else {}
extra.update(env or {})
if image:
flags = [x for k, v in extra.items() for x in ("-e", f"{k}={v}")]
cmd = ["docker", "run", "--rm", "-v", f"{Path.cwd()}:/w", "-w", "/w", *flags, image, "python3", script, *args]
run_env = None
else:
cmd = ["/usr/bin/python3", script, *args]
run_env = {"HOME": os.environ["HOME"], "PATH": "/usr/bin:/bin",
"PYTHONPATH": "/usr/share/qgis/python", **extra}
run = subprocess.run(cmd, capture_output=True, text=True, env=run_env)
lines = [line for line in (run.stdout + run.stderr).splitlines()
if line and not line.startswith(("QThreadStorage", "QStandardPaths"))]
code = 128 - run.returncode if run.returncode < 0 else run.returncode # killed by signal N -> 128 + N
return code, lines or [""]
print(len(gpd.read_file("data/parcels.gpkg")), "parcels,", len(gpd.read_file("data/villages.gpkg")), "villages,",
gpd.read_file("data/district.geojson")["WD24NM"].iloc[0])
21253 parcels, 64 villages, Braunston & Martinsthorpe
Example 1: The wrapper script
%%writefile run-job.sh
#!/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 PYTHONUNBUFFERED=1
export LC_ALL=C.UTF-8
cd "$(dirname "$0")"
exec /usr/bin/python3 catchments.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. The wrapper does not set PROJ_DATA or GDAL_DATA: on a Linux package install the defaults are right, and a stale value is worse than none. (If you do need one, PROJ_DATA is the current name; PROJ_LIB is its deprecated predecessor.)
Example 2: A context manager you can reuse everywhere
%%writefile qgis_headless.py
"""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()
os.environ.setdefault at module import time means the platform is set before any caller can construct an application.
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 .
# the image's Python is "externally managed" (PEP 668): a plain pip3 install is refused.
# A venv with --system-site-packages keeps the qgis bindings importable.
RUN python3 -m venv --system-site-packages /opt/venv \
&& /opt/venv/bin/pip install --no-cache-dir -r requirements.txt
COPY . .
ENTRYPOINT ["/opt/venv/bin/python", "catchments.py"]
A plain RUN pip3 install -r requirements.txt in this image fails with error: externally-managed-environment β tested on qgis/qgis:ltr with QGIS 3.44 on Ubuntu 25.10. PYTHONUNBUFFERED=1 means a container that is killed mid-run still has its log. Pin the base image tag rather than using ltr or latest in production, 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:
%%writefile preflight.py
import os
from pathlib import Path
def preflight(inputs=("data/parcels.gpkg", "data/district.geojson")):
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")
problems += [f"missing input: {name}" for name in inputs if not Path(name).exists()]
if problems:
raise SystemExit("preflight failed:\n - " + "\n - ".join(problems))
print("QGIS", Qgis.QGIS_VERSION, "β preflight OK")
if __name__ == "__main__":
preflight()
print(run_headless("preflight.py")[1])
print(run_headless("preflight.py", platform=None, env={"QGIS_PREFIX_PATH": "/opt/qgis"}))
['QGIS 4.2.2-BelΓ©m do ParΓ‘ β preflight OK']
(1, ['preflight failed:', " - QT_QPA_PLATFORM is not 'offscreen' β a display may be required", ' - prefix path looks wrong: /opt/qgis/share/qgis does not exist'])
The preflight does not even construct an application, so the missing platform is reported as a finding instead of crashing the process.
Example 5: A complete scheduled job
Five-minute walking catchments β 400 m β around Rutland's villages, dissolved and clipped to one ward:
%%writefile catchments.py
#!/usr/bin/env python3
"""Village catchments β 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
villages = QgsVectorLayer("data/villages.gpkg|layername=villages", "villages", "ogr")
if not villages.isValid():
raise RuntimeError("villages layer failed to load")
log.info("loaded %d villages (%s)", villages.featureCount(), villages.crs().authid())
buffered = processing.run("native:buffer", {
"INPUT": villages, "DISTANCE": 400, "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() as app:
from qgis.core import Qgis
app.messageLog().messageReceived.connect(
lambda msg, tag, level: log.warning("[%s] %s", tag, msg) if level >= Qgis.Warning else None)
try:
main()
except Exception:
log.exception("job failed")
sys.exit(1)
log.info("done")
run = subprocess.run(["bash", "run-job.sh"], capture_output=True, text=True,
env={"HOME": os.environ["HOME"], "PATH": "/usr/bin:/bin"})
print("exit", run.returncode)
print("\n".join(line.split(" ", 2)[-1] for line in Path("logs/catchments.log").read_text().splitlines()[-3:]))
catchments = gpd.read_file("out/catchments.gpkg")
print(f"{catchments.area.sum() / 1e6:.2f} kmΒ² of catchment inside the ward")
exit 0
INFO loaded 64 villages (EPSG:27700)
INFO wrote out/catchments.gpkg
INFO done
6.60 kmΒ² of catchment inside the ward
The villages are read inside main(), so every layer is released when it returns β before exitQgis() runs β which is exactly the ordering the segfault section asks for.
qgis.core never needed a display
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 application fails to start β when QgsApplication is constructed, even with the GUI flag off β 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 on both sides of the binding. When the interpreter shuts down with those objects still alive, 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, look for a layer or other QGIS object that outlives exitQgis() β a module-level variable is the usual one, and on the QGIS 3.44 LTR it was enough to turn a clean run into exit 139. Keep layers inside functions, or del them before tearing down.
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.
Platforms, cron, fonts and profiles
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 can produce 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.
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.
Internal links
- How to Automate QGIS with Python (PyQGIS): The Complete Workflow
- How to Run QGIS Processing Algorithms from Python
- How to Fix "ModuleNotFoundError: No module named 'qgis'"
- How to Batch Process Layers in QGIS with PyQGIS
- How to Schedule a Python GIS Script to Run Automatically
- How to Add Retries and Timeouts to an Automated GIS Job
- How to Get Alerted When an Automated GIS Job Fails
- How to Style Layers and Export Map Layouts from PyQGIS
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 β without QT_QPA_PLATFORM=offscreen, QgsApplication([], False) on a machine with no display still aborts with exit 134 β so you want both.
My job writes correct output but exits with code 139. Why?
That is a segmentation fault during interpreter shutdown: either exitQgis() was never called β reproduced here after a Processing run β or a QGIS object such as a layer was still alive, in a global variable, when the interpreter shut down after exitQgis() β reproduced on the QGIS 3.44 LTR. Put exitQgis() in a finally block, keep layers inside functions or del them first, 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; install extra Python packages into a venv created with --system-site-packages, because the image's Python refuses a plain pip install. 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.