Standalone PyQGIS Scripts and Headless Execution
Build production PyQGIS scripts that run without QGIS Desktop — initialise QgsApplication headlessly, load providers, run Processing algorithms, and exit…
Most PyQGIS code is written and tested inside the QGIS Python console, where iface, the provider registry, and the Processing framework are already alive. The moment you move that code into a cron job, a CI pipeline, or a container, none of that scaffolding exists — you have to bootstrap the entire QGIS runtime yourself, run your work, and tear it down cleanly so the process exits without a segfault. This page, part of the Headless Automation, CI/CD & Testing for PyQGIS guide, covers exactly that lifecycle: initialising QgsApplication with no GUI, loading the data providers, wiring in Processing, and exiting cleanly from an unattended batch job.
Get this foundation right and everything downstream — running algorithms, testing, containerisation — becomes straightforward. Get it wrong and you see empty provider registries, invalid layers, missing PROJ data, or intermittent crashes on shutdown that are painful to diagnose in a headless log.
Prerequisites Checklist
Before writing a standalone script, confirm the runtime is complete. Unlike the desktop application, nothing here is auto-configured for you.
- QGIS 3.28+ (LTR recommended): the standalone bootstrap API (
setPrefixPath,initQgis,exitQgis) is stable across the 3.x series, but 3.28 LTR gives you the widest provider and algorithm coverage. - Python 3.9+: matching the interpreter QGIS was compiled against. Mixing a system Python with QGIS libraries built for a different minor version causes import failures in the SIP bindings.
- PyQGIS on
PYTHONPATH:import qgis.coremust resolve. On Linux this is typically/usr/lib/python3/dist-packages; on Windows use the OSGeo4W shell so the paths are set for you. PROJ_LIBset: point it at the PROJ data directory (containingproj.db) so coordinate transformations resolve. A missingPROJ_LIBis the single most common cause of “cannot find grid” and CRS errors in headless runs.QT_QPA_PLATFORM=offscreen: force Qt to use the offscreen platform plugin so it never tries to open an X11/Wayland display. Without this, a script that touches any Qt widget class throwscould not connect to displayon a headless server.- Provider libraries present: the GDAL/OGR and, if needed, PostgreSQL client libraries must be installed and discoverable, since
initQgis()loads them into the registry.
If you are wiring these environment variables into a container or CI image rather than a shell, the companion page on initialising QgsApplication in a headless environment walks through each variable and the platform-specific paths in detail.
How the Standalone Runtime Bootstraps
A standalone script reproduces, by hand, the initialisation the QGIS application normally performs at launch. Understanding the order matters because each step depends on the previous one.
QgsApplication.setPrefixPath(prefix, True) tells QGIS where its install tree lives — the resources, provider plugins, and SRS database all resolve relative to this prefix. The second argument, useDefaultPaths, lets QGIS derive the standard subpaths from that root.
Constructing QgsApplication(argv, False) is the pivotal call. The second positional argument, GUIenabled, is set to False. This is what makes the run headless: QGIS creates a QgsApplication (a subclass of Qt’s QApplication) that never instantiates a GUI platform or a top-level window. The Qt event loop object still exists — you can post events and use signals and slots — but nothing is ever drawn. You still benefit from setting QT_QPA_PLATFORM=offscreen as a belt-and-braces guard against any code path that reaches for a widget.
initQgis() is the call that populates the provider registry. Before it runs, QgsProviderRegistry is effectively empty, so any attempt to load a layer produces an invalid layer with no error you can catch. initQgis() scans the provider plugin directory and registers ogr, gdal, postgres, spatialite, WFS, and the rest. This is why it is non-negotiable: skip it and every QgsVectorLayer you construct reports isValid() == False.
Two subtleties trip people up repeatedly:
- Keep the application reference alive.
QgsApplicationowns C++ resources that layers, providers, and Processing algorithms hold pointers into. If the Python variable holding it goes out of scope and is garbage-collected while those objects still exist, teardown order becomes undefined and the process crashes. Assign it to a variable that outlives all your QGIS work. - Processing is a separate subsystem. The provider registry gives you data access, but the native and GDAL Processing algorithms live in a plugin that must be initialised independently by adding the plugins path to
sys.pathand callingProcessing.initialize().
The diagram below shows the full lifecycle, including the branch where the GUI is never created and the mandatory teardown.
Step-by-Step Implementation
Step 1 — A Minimal Standalone Template
The smallest complete script does five things in order: set the prefix path, construct the application with the GUI disabled, initialise QGIS, do some work, and exit. This template loads a vector layer, reports its feature count and CRS, and shuts down cleanly.
"""Minimal standalone PyQGIS script — load a layer and report on it, headless."""
from __future__ import annotations
import sys
from qgis.core import QgsApplication, QgsVectorLayer
def run(prefix_path: str, layer_path: str) -> int:
"""
Bootstrap QGIS headlessly, inspect a vector layer, and tear down.
Args:
prefix_path: The QGIS install prefix (e.g. "/usr" on Debian/Ubuntu).
layer_path: Path or URI to an OGR-readable vector dataset.
Returns:
Process exit code: 0 on success, 1 if the layer is invalid.
"""
# 1. Point QGIS at its install tree so providers and resources resolve.
QgsApplication.setPrefixPath(prefix_path, True)
# 2. Construct with GUIenabled=False. Keep the instance in a live reference.
qgs = QgsApplication([], False)
# 3. Load the provider registry — without this every layer is invalid.
qgs.initQgis()
exit_code = 0
try:
layer = QgsVectorLayer(layer_path, "input", "ogr")
if not layer.isValid():
# Reaching here with initQgis() called usually means a bad path/driver.
print(f"ERROR: layer failed to load: {layer_path}", file=sys.stderr)
exit_code = 1
else:
print(f"Loaded {layer.featureCount()} features in {layer.crs().authid()}")
finally:
# 4. Mandatory teardown — release C++ resources before the process exits.
qgs.exitQgis()
return exit_code
if __name__ == "__main__":
raise SystemExit(run("/usr", sys.argv[1]))
Two details make this production-safe rather than a toy. First, qgs is a local variable that stays in scope until after exitQgis() runs, so the application is never collected out from under the layer. Second, exitQgis() sits in a finally block, so even if layer processing raises, teardown still happens and the process does not crash on the way out.
Step 2 — A Reusable Context-Manager Wrapper
Repeating the bootstrap and teardown in every script is error-prone — one missing exitQgis() and you have an intermittent crash. Wrapping the lifecycle in a context manager guarantees the teardown runs and gives every script a one-line entry point. This is the pattern worth standardising across a whole automation codebase.
"""Reusable headless QGIS context manager for standalone scripts."""
from __future__ import annotations
import os
import sys
from contextlib import contextmanager
from collections.abc import Iterator
from qgis.core import QgsApplication
@contextmanager
def qgis_app(prefix_path: str = "/usr", with_processing: bool = False) -> Iterator[QgsApplication]:
"""
Bootstrap a headless QGIS runtime and guarantee clean teardown.
Usage:
with qgis_app(with_processing=True) as app:
... # load layers, run algorithms
Args:
prefix_path: QGIS install prefix.
with_processing: If True, also initialise the Processing framework.
Yields:
The live QgsApplication instance for the duration of the block.
"""
# Belt-and-braces: force offscreen rendering even if the caller forgot.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
QgsApplication.setPrefixPath(prefix_path, True)
app = QgsApplication([], False)
app.initQgis()
if with_processing:
# The Processing plugin ships with QGIS but is not on sys.path by default.
sys.path.append(os.path.join(prefix_path, "share", "qgis", "python", "plugins"))
from processing.core.Processing import Processing # noqa: E402
import processing # noqa: F401,E402
Processing.initialize()
try:
yield app
finally:
app.exitQgis()
With that in place, a batch job becomes readable and safe:
"""Batch job: buffer every input and write the result to GeoPackage."""
from qgis.core import QgsVectorLayer
import processing
from qgis_bootstrap import qgis_app # the module above
def buffer_to_gpkg(input_path: str, output_gpkg: str, distance: float) -> None:
"""Run native:buffer headlessly and persist the output to GeoPackage."""
with qgis_app(with_processing=True):
layer = QgsVectorLayer(input_path, "input", "ogr")
if not layer.isValid():
raise RuntimeError(f"Invalid input layer: {input_path}")
processing.run(
"native:buffer",
{
"INPUT": layer,
"DISTANCE": distance,
"OUTPUT": output_gpkg,
},
)
if __name__ == "__main__":
buffer_to_gpkg("parcels.shp", "buffered.gpkg", 25.0)
The context manager owns the lifecycle; the caller owns the work. This separation is what lets you run the same analysis code under cron, under a CI runner, or inside a container without touching the business logic.
Advanced Patterns
Running Processing Algorithms Headlessly
The processing.run() call above hides real subtlety: the algorithm needs an initialised registry, the parameters must match each algorithm’s expected types, and the OUTPUT sink dictates whether the result lands in memory, a GeoPackage, or a Shapefile. Running native algorithms unattended — feeding one algorithm’s output into the next, handling the memory: output type, and reading back the result IDs — has enough moving parts to warrant its own deep-dive. The companion page How to Run a Processing Algorithm Headlessly in Python walks through a complete, drop-in example including error handling and the QgsProcessingFeedback object that surfaces algorithm progress and warnings in your logs.
Choosing a Data Backend
Standalone jobs almost always read and write to disk, and the format you choose has a large effect on throughput and concurrency. A single-file GeoPackage is trivial to ship into a container and needs no server, but it serialises writes; a PostGIS backend gives you concurrent writers, spatial SQL, and server-side indexing at the cost of a running database. Which one suits a given pipeline depends on data volume, whether multiple jobs write simultaneously, and how the output is consumed downstream. The comparison in GeoPackage vs PostGIS as a Processing Backend lays out the trade-offs so you can pick a backend deliberately rather than by default.
Environment Setup and Reproducibility
A script that works on your workstation but fails in CI is nearly always an environment problem: a missing PROJ_LIB, an unset prefix path, or a provider library that is not installed in the runner image. Making the runtime reproducible means pinning the QGIS version, exporting the required variables before the interpreter starts, and verifying the provider registry is populated before doing any work. The full treatment — including how to detect the prefix path at runtime and assert that ogr and gdal registered successfully — lives in Initializing QgsApplication in a Headless Environment. Getting this right is also what keeps CRS handling consistent between environments; the coordinate transformations and CRS handling guide covers the transformation side once PROJ is correctly located.
Pitfalls and Debugging
-
Segfault on exit (missing
exitQgis()or bad GC order): the most common failure. If the process crashes after your work completes, eitherexitQgis()was never called or theQgsApplicationwas garbage-collected while layers still referenced it. Fix: hold the application in a variable that outlives all QGIS objects, and always callexitQgis()from afinallyblock or a context manager. Do not rely onatexit— the interpreter’s teardown order does not guarantee QGIS objects are gone first. -
Empty provider registry (
isValid()is always False): every layer loads as invalid even though the paths are correct. Root cause:initQgis()was not called, orsetPrefixPath()pointed at the wrong directory so the provider plugins were never found. Verify by printingQgsProviderRegistry.instance().providerList()afterinitQgis()— ifograndgdalare absent, the prefix or the install is wrong. -
Missing PROJ data (
cannot find proj.db/ grid errors): any coordinate transformation fails and CRS objects come back invalid. Root cause:PROJ_LIBis unset or points at the wrong directory. Fix: exportPROJ_LIBto the folder containingproj.dbbefore the interpreter starts. This must be set in the environment, not just in Python, because PROJ reads it at library load time. -
could not connect to display(offscreen not set): the script dies the moment it touches any Qt widget or rendering class. Root cause:QT_QPA_PLATFORMwas not set tooffscreen, so Qt tried to open an X11/Wayland connection that does not exist on the server. Fix: exportQT_QPA_PLATFORM=offscreen, or set it in Python before constructingQgsApplication, as the context manager above does. -
Processing algorithms not found (
Algorithm not found):processing.run()raises even though the algorithm id is correct. Root cause: the Processing plugin was never initialised — the provider registry alone does not register algorithms. Fix: add the QGIS plugins path tosys.pathand callProcessing.initialize()before anyprocessing.run()call. -
QgsProjectstate leaking between runs: in long-lived workers that bootstrap once and process many jobs, layers added toQgsProject.instance()accumulate. CallQgsProject.instance().clear()between jobs, or avoid the project registry entirely and hold layers in local variables. The Working with QgsProject and Layer Registry guide covers the registry’s ownership semantics that make this leak possible.
Conclusion
A standalone PyQGIS script is the QGIS runtime, bootstrapped by hand: setPrefixPath() to locate the install, QgsApplication(argv, False) to construct without a GUI, initQgis() to populate the provider registry, optional Processing.initialize() for algorithms, your work, and a mandatory exitQgis() to tear down cleanly. Wrap that lifecycle in a context manager once and every batch job you write inherits safe initialisation and guaranteed teardown. Set QT_QPA_PLATFORM=offscreen and PROJ_LIB in the environment, keep the application reference alive for the whole run, and the same code will run identically under cron, CI, and Docker. With the foundation solid, the next steps — running algorithms, choosing a backend, and reproducing the environment — are all covered in the companion pages below.
Related
- Headless Automation, CI/CD & Testing for PyQGIS — the parent guide covering the full unattended-execution stack
- How to Run a Processing Algorithm Headlessly in Python — a drop-in example of chaining native algorithms without a GUI
- Initializing QgsApplication in a Headless Environment — every environment variable and prefix-path detail for reproducible runs
- GeoPackage vs PostGIS as a Processing Backend — choosing where standalone jobs read and write their data
- Working with QgsProject and Layer Registry — layer ownership and lifecycle semantics that also apply in headless scripts