How to Run a Processing Algorithm Headlessly in Python
Run any QGIS Processing algorithm from a standalone Python script: bootstrap QgsApplication, register the native provider, initialise Processing, then call…
TL;DR: Bootstrap a GUI-less QgsApplication([], False), register the native algorithms with QgsApplication.processingRegistry().addProvider(QgsNativeAlgorithms()), call Processing.initialize(), then run processing.run("native:buffer", {...}) and read the returned dict — and always finish with QgsApplication.exitQgis().
This page is part of the Standalone PyQGIS Scripts and Headless Execution guide, which sits inside the broader Headless Automation, CI/CD & Testing for PyQGIS reference. If you can already open QGIS Desktop and run a tool from the Processing Toolbox, everything you do there is available from a plain Python interpreter — but only after three initialisation steps that the desktop performs for you invisibly. Skip any one of them and processing.run() raises QgsProcessingException: Error: Algorithm native:buffer not found, because the algorithm registry is empty.
Complete Runnable Script
Drop this template into any standalone script, cron job, or CI runner. It bootstraps QGIS with no GUI, registers the built-in (“native”) algorithms, initialises the Processing framework, runs a real algorithm against an input file, and prints the result dictionary. Swap the algorithm id and parameters — the surrounding scaffolding never changes.
"""
run_algorithm_headless.py
Run a single QGIS Processing algorithm from a standalone Python script,
with no GUI and no running QGIS Desktop instance.
Compatible with QGIS 3.28+ LTR.
Usage:
python run_algorithm_headless.py /data/roads.gpkg /data/roads_buffered.gpkg
"""
from __future__ import annotations
import sys
from typing import Any
from qgis.core import (
QgsApplication,
QgsProcessingFeedback,
QgsVectorLayer,
)
def bootstrap_qgis() -> QgsApplication:
"""
Start a GUI-less QGIS application and load all core providers.
setPrefixPath must point at the QGIS install root (the directory
that contains 'bin', 'lib' and 'share/qgis'). '/usr' is correct for
most Linux packages; adjust for OSGeo4W or conda-forge installs.
"""
QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False) # second arg False = no GUI
qgs.initQgis()
return qgs
def init_processing() -> None:
"""
Register the native algorithm provider and initialise Processing.
Order matters: the native provider must be added to the registry
before Processing.initialize() so that 'native:*' algorithm ids
resolve. In a headless script neither step happens automatically.
"""
from qgis.analysis import QgsNativeAlgorithms
QgsApplication.processingRegistry().addProvider(QgsNativeAlgorithms())
from processing.core.Processing import Processing
Processing.initialize()
def run_buffer(src_path: str, out_path: str, distance: float = 50.0) -> dict[str, Any]:
"""
Buffer every feature in a vector layer by a fixed distance.
Args:
src_path: Path to the input vector (any OGR-readable format).
out_path: Destination GeoPackage path for the buffered output.
distance: Buffer distance in the layer's map units.
Returns:
The result dictionary from processing.run(); result["OUTPUT"]
holds the written output path.
Raises:
FileNotFoundError: the input layer could not be loaded.
"""
import processing # import only after Processing.initialize()
layer = QgsVectorLayer(src_path, "input", "ogr")
if not layer.isValid():
raise FileNotFoundError(f"Could not load a valid layer from {src_path!r}.")
feedback = QgsProcessingFeedback()
params: dict[str, Any] = {
"INPUT": layer,
"DISTANCE": distance,
"SEGMENTS": 8,
"DISSOLVE": False,
"OUTPUT": out_path,
}
return processing.run("native:buffer", params, feedback=feedback)
def main(argv: list[str]) -> int:
"""Bootstrap QGIS, run the algorithm, print the result, then exit cleanly."""
if len(argv) != 3:
print("usage: run_algorithm_headless.py <input> <output.gpkg>")
return 2
src_path, out_path = argv[1], argv[2]
qgs = bootstrap_qgis()
try:
init_processing()
result = run_buffer(src_path, out_path)
print(f"Algorithm finished. OUTPUT = {result['OUTPUT']}")
return 0
except Exception as exc: # log and surface a non-zero exit code to CI
print(f"Processing failed: {exc}", file=sys.stderr)
return 1
finally:
qgs.exitQgis() # always release native resources
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
To reproject instead of buffering, keep every line the same and change one call: processing.run("native:reprojectlayer", {"INPUT": layer, "TARGET_CRS": "EPSG:32632", "OUTPUT": out_path}). That interchangeability is the whole point of the Processing framework — a uniform run(id, params) contract over hundreds of algorithms.
Initialisation Flow
The sequence below is the minimum viable bootstrap. The three grey initialisation steps are exactly what QGIS Desktop does at start-up; in a standalone script you own them.
Architecture Breakdown
Processing.initialize() — what it registers and why it is required
Processing.initialize() (from processing.core.Processing) is the Python entry point that populates the Processing framework. It registers the parameter type definitions (QgsProcessingParameterNumber, ...Feature Source, ...RasterDestination, and dozens more), wires up the algorithm providers that ship with QGIS, and installs the expression functions Processing uses internally. Crucially, it also loads the GDAL, GRASS and SAGA providers if their executables are discoverable — but it does not register the native (C++) algorithms. Those live in qgis.analysis and must be added to the registry yourself.
That is why the two calls appear together and in a fixed order. First QgsApplication.processingRegistry().addProvider(QgsNativeAlgorithms()) makes the native: provider available; then Processing.initialize() finalises the framework around it. In QGIS Desktop the plugin manager runs the equivalent bootstrap during start-up, which is why you never see it — but a standalone interpreter starts with an empty registry, and calling processing.run() before initialisation raises QgsProcessingException: Algorithm native:buffer not found. Initialisation is idempotent: calling it twice is harmless, so it is safe to call defensively at the top of a reusable module.
processing.run() versus processing.runAndLoadResults()
processing.run(algorithm_id, parameters, feedback=None, context=None) executes an algorithm and returns a plain dictionary of outputs. It has no dependency on a map canvas, a layer tree, or a loaded project — which makes it the only correct choice for headless work. The return value maps each output parameter name to its value; for file outputs, result["OUTPUT"] is the written path (or a layer id, depending on the algorithm), which you then load or pass to the next step.
processing.runAndLoadResults() does everything run() does and then adds the result layers to QgsProject.instance() and, in a desktop session, styles and displays them. That extra behaviour assumes a live GUI and project context, so in a QgsApplication([], False) script it either does nothing useful or fails. Rule of thumb: run() for automation, runAndLoadResults() only for interactive console use. If you need the output as an in-memory object rather than a file, pass "OUTPUT": "memory:" and read the resulting layer from the returned dict.
Algorithm id, parameter dict, and QgsProcessingFeedback
Every algorithm is addressed by a provider:name id — native:buffer, native:reprojectlayer, gdal:warpreproject. Discover ids and their exact parameter names from Python rather than guessing: QgsApplication.processingRegistry().algorithms() lists everything, and processing.algorithmHelp("native:buffer") prints each parameter, its type, and default. The parameter dictionary keys are those upper-case names; values may be a layer object, a file path string, a numeric literal, or a CRS string such as "EPSG:32632".
QgsProcessingFeedback is the channel through which an algorithm reports progress, informational messages, and errors. Passing an instance to run(..., feedback=feedback) lets you capture that stream — essential in CI, where you want the algorithm’s own log lines in your build output. Subclass it to redirect messages to a logger, or use the base class to have them printed. Without a feedback object the algorithm still runs, but its diagnostics are discarded.
Wrapping run() as a Reusable Function
For CI pipelines and batch jobs, wrap the call in a small function with a custom feedback object that forwards Processing’s messages to Python’s standard logging. This gives you timestamped, level-tagged lines in your build logs and a single place to enforce error handling.
"""Reusable headless Processing runner with CI-friendly logging."""
from __future__ import annotations
import logging
from typing import Any
from qgis.core import QgsProcessingException, QgsProcessingFeedback
logger = logging.getLogger("pyqgis.processing")
class LoggingFeedback(QgsProcessingFeedback):
"""Forward Processing progress and messages to the Python logger."""
def pushInfo(self, info: str) -> None: # noqa: N802 (Qt naming)
logger.info(info)
def reportError(self, error: str, fatalError: bool = False) -> None: # noqa: N802
logger.error(error)
def setProgress(self, progress: float) -> None: # noqa: N802
logger.debug("progress: %.0f%%", progress)
def run_algorithm(algorithm_id: str, params: dict[str, Any]) -> dict[str, Any]:
"""
Run a Processing algorithm and raise on failure.
Assumes bootstrap_qgis() and init_processing() have already run.
Raises:
QgsProcessingException: the algorithm reported a fatal error.
"""
import processing
feedback = LoggingFeedback()
try:
result = processing.run(algorithm_id, params, feedback=feedback)
except QgsProcessingException as exc:
logger.error("Algorithm %s failed: %s", algorithm_id, exc)
raise
logger.info("Algorithm %s produced: %s", algorithm_id, sorted(result))
return result
Because processing.run() raises QgsProcessingException for fatal algorithm errors (a missing input, an invalid geometry it refuses to process, an unwritable output path), catching that specific type lets a CI job fail loudly with a meaningful message instead of silently producing a broken artifact.
Production Best Practices
- Check output validity, do not trust the return alone. After
run(), loadresult["OUTPUT"]as aQgsVectorLayer/QgsRasterLayerand assertisValid()before declaring success — a path can be returned even when the write was partial. - Always pass a
QgsProcessingFeedback. In headless runs it is your only window into what the algorithm did; a subclass that forwards tologgingturns Processing chatter into searchable CI logs. - Use
"memory:"outputs for intermediate steps. Chaining algorithms in RAM avoids writing throwaway files to disk; only materialise the final result to a GeoPackage. - Use absolute paths for every input and output. Headless scripts run from unpredictable working directories (cron, CI containers), so relative paths are a common, silent source of “file not found” and misplaced outputs.
- Treat a non-empty error stream as failure. Wrap
run()intry/except QgsProcessingExceptionand return a non-zero exit code so the pipeline stops rather than shipping a bad output. - Register providers once, run many times. Call
bootstrap_qgis()andinit_processing()a single time per process, then invokerun()in a loop — re-initialising per iteration wastes seconds and leaks nothing you need. - Always call
exitQgis()in afinallyblock. It flushes native resources and PROJ caches; skipping it can leave file handles open and, in long-running workers, slowly exhaust memory.
For the environment set-up that must precede any of this — prefix paths, PROJ_LIB, and the empty-argv QgsApplication idiom — see initializing QgsApplication in a headless environment.
Related
- Standalone PyQGIS Scripts and Headless Execution — parent guide covering the full lifecycle of GUI-less PyQGIS scripts, from bootstrap to output validation
- Initializing QgsApplication in a Headless Environment — the prefix path, environment variables, and provider loading that must happen before any algorithm runs
- Running QGIS Plugin Tests in GitHub Actions — put this headless runner inside a CI job with the right QGIS container and feedback logging