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.

python
"""
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.

Headless Processing Initialisation Flow A left-to-right pipeline: bootstrap QgsApplication with no GUI, register the native provider, call Processing.initialize, then processing.run, then handle the OUTPUT, and finally exitQgis. bootstrap QgsApplication register native provider Processing .initialize() processing .run() handle OUTPUT exit Qgis() grey steps = one-time initialisation the desktop performs for you

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.

python
"""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(), load result["OUTPUT"] as a QgsVectorLayer/QgsRasterLayer and assert isValid() 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 to logging turns 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() in try/except QgsProcessingException and 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() and init_processing() a single time per process, then invoke run() in a loop — re-initialising per iteration wastes seconds and leaks nothing you need.
  • Always call exitQgis() in a finally block. 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