How to Automate QGIS from the Command Line with qgis_process

On this page

Problem statement

You want one QGIS algorithm run from a shell script, a Makefile or a cron entry. Writing a PyQGIS script for it means ceremony before the actual work:

qgs = QgsApplication([], False)
qgs.initQgis()
sys.path.append("/usr/share/qgis/python/plugins")
from processing.core.Processing import Processing
Processing.initialize()
# … and a matching teardown, or the process can segfault on exit

qgis_process is the CLI QGIS ships for exactly this. It initialises the application and the Processing registry, runs one algorithm, writes the output, and exits β€” every time, in its own process.

qgis_process run native:buffer -- INPUT=data/rutland.gpkg DISTANCE=25 OUTPUT=out/buffered.gpkg

The reasons to reach for it:

  • no application lifecycle to manage, so no segfault-at-exit class of bug
  • a crash is confined to one invocation rather than killing a long-running script
  • it composes with shell tools: xargs, make, CI steps
  • the same command works on a laptop, a server and inside a Docker image
  • models built in the GUI can be run directly, with no Python at all

Every command on this page was run with QGIS 4.2's qgis_process and, where marked, the 3.44 one in the official qgis/qgis:ltr image, on Rutland's OpenStreetMap extract and Land Registry parcels.

Quick answer

Discover, inspect, then run β€” and check the exit code:

  1. qgis_process list to find the algorithm id
  2. qgis_process help <id> for the exact parameter names and enum values
  3. qgis_process run <id> -- KEY=VALUE … to execute it
  4. add --json when a script has to read the result
  5. set QT_QPA_PLATFORM=offscreen on a headless machine
shell("""
export QT_QPA_PLATFORM=offscreen
qgis_process list | grep -ci buffer
qgis_process help native:buffer | head -1
qgis_process run native:buffer -- \\
  INPUT="data/rutland.gpkg|layername=parcels" \\
  DISTANCE=25 SEGMENTS=8 DISSOLVE=false \\
  OUTPUT="out/parcels_buffered.gpkg" | grep '^OUTPUT'
echo "exit=${PIPESTATUS[0]}"
""")
11
Buffer (native:buffer)
OUTPUT:	out/parcels_buffered.gpkg
exit=0

The -- separator matters: everything after it is algorithm parameters, everything before it belongs to qgis_process itself.

Anatomy of a qgis_process command

The anatomy of a qgis_process command. five parts, and the -- mattersqgis_processthe binaryruncommandnative:bufferalgorithm id--separatorINPUT=… DISTANCE=25parametersqgis_process help <id> lists every parameter and enum valueadd --json when a script has to read the result spatialworkflow.io
Five parts β€” the -- is what separates the tool's own flags from the algorithm's.

Discover, read parameters, run, chain

Using qgis_process from discovery through execution to chaining. stop guessing parameter names1set QT_QPA_PLATFORMoffscreen2qgis_process listfind the id3qgis_process help <id>names + enums4run with -- parametersone algorithm5check the exit codeand the output file6chain in shell or makeeach step isolatedEach run is its own process, so a crash costs one step spatialworkflow.io
Six steps β€” the middle two remove all the guessing about parameter names.

Check it is installed and headless-ready

shell("""
which qgis_process
qgis_process --version | head -3
docker run --rm qgis/qgis:ltr qgis_process --version 2>/dev/null | head -1
""")
/usr/bin/qgis_process
QGIS 4.2.2-BelΓ©m do ParΓ‘ 'BelΓ©m do ParΓ‘' (f1431de8676)
QGIS code revision f1431de8676
Qt version 6.10.2
QGIS 3.44.15-Solothurn 'Solothurn' (44b4ef05178)

It ships with QGIS on all platforms, and in the official Docker image it is already on the path. On a server with no display, set QT_QPA_PLATFORM=offscreen; the only other thing a container complained about was XDG_RUNTIME_DIR not set, a warning it then worked around by itself. On Windows the runner is a .bat file in the installation's bin folder and on macOS it is inside QGIS.app/Contents/MacOS/bin β€” those two were not run for this page.

Discover the algorithm

shell("""
qgis_process list | grep -c $'\\t'                      # every algorithm, one per line
qgis_process list | grep -i dissolve
qgis_process list --no-python | grep -c $'\\t'          # without Python-based providers
""")
738
	gdal:dissolve	Dissolve
	grass:v.dissolve	v.dissolve
	native:coverageunion	Dissolve coverage
	native:dissolve	Dissolve
350

The output is the id followed by the display name, grouped by provider β€” which makes it the fastest way to confirm whether GRASS or a plugin provider is actually available in this installation. --no-python starts faster but leaves out every provider written in Python: here only the 350 native: algorithms were left, with gdal:, grass: and qgis: gone.

Read the parameters before guessing

shell("qgis_process help native:buffer | sed -n '/^END_CAP_STYLE/,/^JOIN_STYLE/p'")
END_CAP_STYLE: End cap style
	Default value:	0
	Argument type:	enum
	Available values:
		- 0: Round
		- 1: Flat
		- 2: Square
	Acceptable values:
		- Number of selected option, e.g. '1'
		- Comma separated list of options, e.g. '1,3'
JOIN_STYLE: Join style

This is the authoritative list for your installed version, including the integer behind each enum and the default. It is the CLI equivalent of processing.algorithmHelp(), and it is worth reading, because the label is not accepted:

shell("""
for cap in Flat 1; do
  qgis_process run native:buffer -- "INPUT=data/rutland.gpkg|layername=landuse" DISTANCE=5 \\
      END_CAP_STYLE=$cap OUTPUT=TEMPORARY_OUTPUT 2>&1 | grep -E 'Incorrect|OUTPUT:\s+Buffered' | sed 's/_[0-9a-f_]*$//'
  echo "  END_CAP_STYLE=$cap -> exit ${PIPESTATUS[0]}"
done
""")
	Incorrect parameter value for END_CAP_STYLE
  END_CAP_STYLE=Flat -> exit 1
OUTPUT:	Buffered
  END_CAP_STYLE=1 -> exit 0

Run it, and pass parameters correctly

shell("""
qgis_process run native:buffer -- \\
  INPUT="data/rutland.gpkg|layername=parcels" DISTANCE=25 SEGMENTS=8 \\
  END_CAP_STYLE=0 JOIN_STYLE=0 MITER_LIMIT=2 DISSOLVE=false \\
  OUTPUT="out/parcels_buffered.gpkg" | grep '^OUTPUT'

# multiple inputs: repeat the key (all of one geometry type - polygons here)
qgis_process run native:mergevectorlayers -- LAYERS=data/osm/gis_osm_water_a_free_1.shp \\
  LAYERS=data/osm/gis_osm_natural_a_free_1.shp OUTPUT=out/water.gpkg | grep '^OUTPUT'
qgis_process run native:mergevectorlayers -- LAYERS=data/osm/gis_osm_water_a_free_1.shp \\
  LAYERS=data/osm/gis_osm_waterways_free_1.shp OUTPUT=out/mixed.gpkg 2>&1 | grep '^ERROR'

# extent: xmin,xmax,ymin,ymax [CRS] - Oakham town centre
qgis_process run native:extractbyextent -- "INPUT=data/rutland.gpkg|layername=parcels" \\
  EXTENT="485500,486500,308500,309500 [EPSG:27700]" CLIP=false OUTPUT=out/oakham.gpkg | grep '^OUTPUT'
ogrinfo -so -q out/oakham.gpkg | head -1; ogrinfo -so out/oakham.gpkg oakham 2>/dev/null | grep 'Feature Count'
""")
OUTPUT:	out/parcels_buffered.gpkg
OUTPUT:	out/water.gpkg
ERROR:	All layers must have same geometry type! Encountered a Line layer when expecting a Polygon layer.
OUTPUT:	out/oakham.gpkg
1: oakham (Polygon)
Feature Count: 1348

Repeating LAYERS merged two polygon layers; handing it a polygon layer and a line layer failed with "All layers must have same geometry type!" β€” help does not say that, the algorithm does. The value conventions, as used above:

INPUT="data/rutland.gpkg|layername=parcels"   # multi-layer file: the layer URI form
LAYERS=a.shp LAYERS=b.shp                     # multiple inputs: repeat the key
END_CAP_STYLE=1                               # enum: the integer from `help`
DISSOLVE=true                                 # boolean: true / false
EXTENT="485500,486500,308500,309500 [EPSG:27700]"   # xmin,xmax,ymin,ymax [CRS]
TARGET_CRS=EPSG:27700                         # CRS
OUTPUT=TEMPORARY_OUTPUT                       # when you only want the summary

Note the extent order β€” xmin,xmax,ymin,ymax, not the minx,miny,maxx,maxy used almost everywhere else.

Five qgis_process commands from this page and how each one ended. what the exit code told uscommandexitwhat it printedEND_CAP_STYLE=Flat1Incorrect parameter valueEND_CAP_STYLE=10OUTPUT: BufferedLAYERS= polygon + lineerrorsame geometry type!INPUT=data/missing.gpkg1checking parameter valuesmodel run with OUTPUT=errormandatory buf:OUTPUThelp would have predicted all but the merge, which failed inside the algorithmRead help first, then trust the exit code β€” and still check the output file spatialworkflow.io
The step above and the model section below, summarised: every one of these failed loudly, which is the point of checking.

Read the result in a script

shell("""
qgis_process run native:buffer --json -- \\
  "INPUT=data/rutland.gpkg|layername=parcels" DISTANCE=25 OUTPUT=out/buf.gpkg 2>/dev/null \\
  | tee out/result.json | jq -r '.results.OUTPUT'
jq -r 'keys | join(", ")' out/result.json
""")
out/buf.gpkg
algorithm_details, gdal_version, geographiclib_version, geos_version, inputs, log, proj_version, provider_details, python_version, qgis_code_revision, qgis_version, qt_version, results, sfcgal_version
shell("""
set -euo pipefail
output=$(qgis_process run native:buffer --json -- \\
  "INPUT=data/rutland.gpkg|layername=parcels" DISTANCE=25 OUTPUT=out/buf.gpkg 2>/dev/null)
path=$(echo "$output" | jq -r '.results.OUTPUT')
[ -f "$path" ] || { echo "no output produced" >&2; exit 1; }
echo "wrote $path"

qgis_process run native:buffer -- INPUT=data/missing.gpkg DISTANCE=25 OUTPUT=out/x.gpkg >/dev/null 2>&1 \\
  || echo "a missing input: exit $?"
""")
wrote out/buf.gpkg
a missing input: exit 1

--json prints a machine-readable object with the results, the algorithm details, the inputs and the log. Combined with set -euo pipefail, a failure stops the script rather than being carried into the next step β€” a missing input exited with a non-zero code.

The options, from qgis_process --help on 4.2:

--json                 # machine-readable output
--verbose              # log everything the algorithm reports
--no-python            # skip Python support: faster start-up, fewer providers
--skip-loading-plugins # do not load enabled plugins
# after the --, with the parameters:
--PROJECT_PATH=path.qgz   # run with a project loaded, for layers referenced by name
--ELLIPSOID=EPSG:7030     # ellipsoid for distance and area calculations

--PROJECT_PATH is what lets an algorithm resolve layers, variables and relations defined in a .qgz β€” necessary for models that reference project layers. It goes after the --, with the algorithm's parameters.

Chain algorithms in a shell pipeline

shell("""
set -euo pipefail
SRC="data/rutland.gpkg|layername=landuse"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT

qgis_process run native:fixgeometries -- INPUT="$SRC" OUTPUT="$TMP/fixed.gpkg" >/dev/null
qgis_process run native:reprojectlayer -- INPUT="$TMP/fixed.gpkg" TARGET_CRS=EPSG:4326 \\
  OUTPUT="$TMP/wgs84.gpkg" >/dev/null
qgis_process run native:reprojectlayer -- INPUT="$TMP/wgs84.gpkg" TARGET_CRS=EPSG:27700 \\
  OUTPUT="$TMP/projected.gpkg" >/dev/null
qgis_process run native:buffer -- INPUT="$TMP/projected.gpkg" DISTANCE=25 DISSOLVE=false \\
  OUTPUT="$TMP/buffered.gpkg" >/dev/null
qgis_process run native:dissolve -- INPUT="$TMP/buffered.gpkg" FIELD=fclass \\
  OUTPUT=out/landuse_zones.gpkg >/dev/null
ogrinfo -so out/landuse_zones.gpkg landuse_zones 2>/dev/null | grep 'Feature Count' || \\
  ogrinfo -so -al out/landuse_zones.gpkg | grep 'Feature Count'
""")
Feature Count: 19

Each step is a separate process, so a crash in one is contained and the shell's set -e stops the chain immediately. Rutland's 2,405 land-use polygons came out as one dissolved zone per class.

Run a model built in the GUI

The setup below builds a one-step model (a 500 m buffer) the way the Graphical Modeler would, and saves it as models/school_buffer.model3.

shell("""
qgis_process help models/school_buffer.model3 | sed -n '/^Arguments/,/^Outputs/p' | grep -E '^[A-Za-z:_]+:'
qgis_process run models/school_buffer.model3 -- INPUT=data/schools.gpkg OUTPUT=out/cleaned.gpkg 2>&1 \\
  | grep -E 'ERROR|mandatory|OUTPUT' | head -3
qgis_process run models/school_buffer.model3 -- INPUT=data/schools.gpkg "buf:OUTPUT=out/cleaned.gpkg" \\
  | grep 'OUTPUT'
""")
INPUT: Input
buf:OUTPUT: 
OUTPUT:	out/cleaned.gpkg
ERROR: The following mandatory parameters were not specified
	buf:OUTPUT:	
buf:OUTPUT:	out/cleaned.gpkg

The model's output is not called OUTPUT. It is named after the child algorithm and its output β€” buf:OUTPUT here β€” which is exactly the parameter the obvious command left out. Read the names with help first. Models saved in the QGIS Desktop profile also appear in qgis_process list with a model: prefix, but running the .model3 file by path is what you want in CI, where no profile exists.

This is the payoff of the Graphical Modeler: a non-programmer builds the workflow visually, and the pipeline runs the exact same artefact unattended.

Batching Rutland's OpenStreetMap layers from the shell

Seconds taken by qgis_process: 18 Rutland shapefiles reprojected one and four at a time, and one small run with and without Python. what the process-per-run model costs18 files, 1 at a time7.8 s18 files, 4 at a time2.5 sone run, default1.24 sone run, --no-python0.41 s02 s4 s6 s8 sParallel workers scale because each is its own process; start-up is mostly Python spatialworkflow.io
Examples 1 and 5 in one chart, measured with a load average around 3.5 on 24 CPUs.

The data used on this page

Geofabrik's OpenStreetMap extract for Rutland of 1 January 2026, 18 shapefiles (Β© OpenStreetMap contributors, ODbL), with its land use and schools also in a GeoPackage; and HM Land Registry's INSPIRE Index Polygons for the county (Β© Crown copyright and database rights, HM Land Registry; OGL).

import json
import os
import subprocess
import time
import zipfile
from pathlib import Path

import requests
from osgeo import gdal

gdal.UseExceptions()
for folder in ("data", "out", "models"):
    Path(folder).mkdir(exist_ok=True)
session = requests.Session()
if not Path("data/rutland.gpkg").exists():
    Path("data/rutland.shp.zip").write_bytes(session.get(
        "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
    with zipfile.ZipFile("data/rutland.shp.zip") as z:
        z.extractall("data/osm")
    gdal.VectorTranslate("data/rutland.gpkg", "data/osm/gis_osm_landuse_a_free_1.shp", layerName="landuse",
                         dstSRS="EPSG:27700", reproject=True, selectFields=["fclass"])
    gdal.VectorTranslate("data/schools.gpkg", "data/osm/gis_osm_pois_a_free_1.shp", layerName="schools",
                         dstSRS="EPSG:27700", reproject=True, where="fclass = 'school'", selectFields=["name"])
    Path("data/Rutland.zip").write_bytes(session.get(
        "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300).content)
    gdal.VectorTranslate("data/rutland.gpkg", "/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
                         accessMode="append", layerName="parcels", SQLStatement="SELECT INSPIREID FROM PREDEFINED")

ENV = dict(os.environ, QT_QPA_PLATFORM="offscreen")


def shell(script):
    """Run a bash script as it is written on the page, and print what it printed."""
    run = subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=ENV)
    noise = ("XDG_RUNTIME_DIR", "QStandardPaths", "Using non-preferred coordinate operation")
    lines = []
    for line in (run.stdout + run.stderr).splitlines():
        if line.strip() and not any(n in line for n in noise) and (not lines or line != lines[-1]):
            lines.append(line)
    print("\n".join(lines).replace(str(Path.cwd()), "."))


# the model a Graphical Modeler user would save: INPUT -> native:buffer (500 m)
model_script = '''
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import *
app = QgsApplication([], False); app.initQgis()
m = QgsProcessingModelAlgorithm("school_buffer", "Models")
m.addModelParameter(QgsProcessingParameterVectorLayer("INPUT", "Input"), QgsProcessingModelParameter("INPUT"))
child = QgsProcessingModelChildAlgorithm("native:buffer"); child.setChildId("buf")
child.addParameterSources("INPUT", [QgsProcessingModelChildParameterSource.fromModelParameter("INPUT")])
child.addParameterSources("DISTANCE", [QgsProcessingModelChildParameterSource.fromStaticValue(500)])
out = QgsProcessingModelOutput("OUTPUT"); out.setChildId("buf"); out.setChildOutputName("OUTPUT")
child.setModelOutputs({"OUTPUT": out}); m.addChildAlgorithm(child); m.updateDestinationParameters()
m.toFile("models/school_buffer.model3"); app.exitQgis()
'''
subprocess.run(["python3", "-c", model_script], env=dict(ENV, PYTHONPATH="/usr/share/qgis/python"), check=True,
               capture_output=True)
print(len(list(Path("data/osm").glob("*.shp"))), "shapefiles;", Path("models/school_buffer.model3").exists(), "model saved")
18 shapefiles; True model saved

Example 1: Batch a folder, four at a time

shell("""
set -euo pipefail
mkdir -p out/batch
process_one() {
  local shp="$1" name
  name=$(basename "${shp%.*}")
  if qgis_process run native:reprojectlayer --no-python -- INPUT="$shp" TARGET_CRS=EPSG:27700 \\
       OUTPUT="out/batch/${name}.gpkg" >/dev/null 2>&1; then echo "ok   $name"; else echo "FAIL $name"; fi
}
export -f process_one
for jobs in 1 4; do
  rm -f out/batch/*.gpkg
  start=$(date +%s.%N)
  find data/osm -name '*.shp' -print0 | xargs -0 -P "$jobs" -I{} bash -c 'process_one "$@"' _ {} > out/batch.log
  printf '%d at a time: %d ok, %d failed, %.1f s\\n' "$jobs" "$(grep -c '^ok' out/batch.log)" \\
    "$(grep -c '^FAIL' out/batch.log || true)" "$(echo "$(date +%s.%N) - $start" | bc)"
done
""")
1 at a time: 18 ok, 0 failed, 7.8 s
4 at a time: 18 ok, 0 failed, 2.5 s

Because each invocation is its own process, four at a time is genuinely parallel β€” and one bad file would kill only its own worker. xargs -P needs nothing installed; GNU parallel does the same with a progress bar if you have it. --no-python shaves start-up time when, as here, only native: algorithms are needed.

Example 2: Drive it from Python without initialising QGIS

"""Run QGIS algorithms as subprocesses - no PyQGIS in this interpreter at all."""


def qgis_run(algorithm: str, params: dict, timeout: int = 900) -> dict:
    args = ["qgis_process", "run", algorithm, "--json", "--"]
    for key, value in params.items():
        if isinstance(value, (list, tuple)):
            args += [f"{key}={v}" for v in value]
        elif isinstance(value, bool):
            args.append(f"{key}={'true' if value else 'false'}")
        else:
            args.append(f"{key}={value}")

    proc = subprocess.run(args, capture_output=True, text=True, env=ENV, timeout=timeout)
    if proc.returncode != 0:
        raise RuntimeError(f"{algorithm} failed (exit {proc.returncode}): {proc.stderr.strip()[-300:]}")
    return json.loads(proc.stdout)


start = time.perf_counter()
result = qgis_run("native:buffer", {
    "INPUT": "data/rutland.gpkg|layername=parcels",
    "DISTANCE": 25,
    "DISSOLVE": False,
    "OUTPUT": "out/buffered.gpkg",
})
print(result["results"]["OUTPUT"], f"in {time.perf_counter() - start:.1f} s |", "qgis imported here:",
      "qgis" in __import__("sys").modules)
try:
    qgis_run("native:buffer", {"INPUT": "data/missing.gpkg", "DISTANCE": 25, "OUTPUT": "out/x.gpkg"})
except RuntimeError as exc:
    print("RuntimeError:", str(exc).splitlines()[0][:110])
out/buffered.gpkg in 2.7 s | qgis imported here: False
RuntimeError: native:buffer failed (exit 1): ERROR:	An error was encountered while checking parameter values

This is the most robust way to use QGIS from an ordinary Python program: no import qgis, no lifecycle, no segfaults, and a timeout you control.

Example 3: A Makefile that only redoes what changed

Path("Makefile").write_text('''export QT_QPA_PLATFORM := offscreen

RAW  := data/rutland.gpkg
FIX  := build/fixed.gpkg
OUT  := out/landuse_by_class.gpkg

.PHONY: all clean
all: $(OUT)

build:
\tmkdir -p build out

$(FIX): $(RAW) | build
\tqgis_process run native:fixgeometries -- "INPUT=$<|layername=landuse" OUTPUT=$@ > /dev/null

$(OUT): $(FIX)
\tqgis_process run native:dissolve -- INPUT=$< FIELD=fclass OUTPUT=$@ > /dev/null

clean:
\trm -rf build $(OUT)
''')
shell("""
make clean >/dev/null
make 2>&1 | grep -c qgis_process
make 2>&1 | tail -1
touch data/rutland.gpkg; make 2>&1 | grep -c qgis_process
""")
2
make: Nothing to be done for 'all'.
2

make gives you incremental rebuilds for free: the first run ran both steps, the second had nothing to do, and touching the input re-ran both again.

Example 4: The CI steps, run in the image a CI job would use

Path("ci_check.py").write_text("""from osgeo import ogr
ogr.UseExceptions()
dataset = ogr.Open("out/ci.gpkg")          # keep the dataset: a layer dies with it
n = dataset.GetLayer(0).GetFeatureCount()
print(n, "features")
raise SystemExit(0 if n > 0 else 1)
""")
shell("""
rm -f out/ci.gpkg
docker run --rm -v "$PWD":/work -w /work --user "$(id -u):$(id -g)" -e HOME=/tmp -e QT_QPA_PLATFORM=offscreen \\
  qgis/qgis:ltr bash -c '
    set -e
    qgis_process --version 2>/dev/null | head -1
    qgis_process list 2>/dev/null | grep -q "native:buffer" || { echo "missing algorithm"; exit 1; }
    qgis_process run models/school_buffer.model3 --json -- INPUT=data/schools.gpkg buf:OUTPUT=out/ci.gpkg \\
      > out/ci.json 2>/dev/null
    python3 ci_check.py'
echo "exit=$?"
""")
QGIS 3.44.15-Solothurn 'Solothurn' (44b4ef05178)
26 features
exit=0

The check keeps the dataset in a variable on purpose. The first version chained ogr.Open(...).GetLayer(0), the dataset was freed as soon as the layer was taken from it, and the image's GDAL failed with a TypeError on a perfectly good file. The same three steps go into a GitHub Actions job with container: qgis/qgis:ltr (pin a dated tag in real use):

# .github/workflows/geoprocess.yml
jobs:
  process:
    runs-on: ubuntu-latest
    container: qgis/qgis:ltr
    env:
      QT_QPA_PLATFORM: offscreen
    steps:
      - uses: actions/checkout@v4
      - name: Check the algorithm exists
        run: qgis_process list | grep -q 'native:buffer' || (echo "missing algorithm" && exit 1)
      - name: Run the model
        run: qgis_process run models/school_buffer.model3 --json -- INPUT=data/schools.gpkg buf:OUTPUT=out/ci.gpkg > result.json
      - name: Check the output
        run: python3 ci_check.py
      - uses: actions/upload-artifact@v4
        with: { name: school-buffers, path: out/ci.gpkg }

The workflow file itself was not run on GitHub for this page; its commands were run in the same image above.

Example 5: What one invocation costs

shell("""
for flags in "" "--no-python" "--no-python --skip-loading-plugins"; do
  start=$(date +%s.%N)
  for i in 1 2 3; do qgis_process $flags run native:centroids -- INPUT=data/schools.gpkg OUTPUT=TEMPORARY_OUTPUT >/dev/null 2>&1; done
  printf '%-38s %.2f s per run\\n' "${flags:-(default)}" "$(echo "($(date +%s.%N) - $start) / 3" | bc -l)"
done
printf 'load average %s\\n' "$(cut -d' ' -f1 /proc/loadavg)"
""")
(default)                              1.24 s per run
--no-python                            0.41 s per run
--no-python --skip-loading-plugins     0.41 s per run
load average 3.48

Most of that is start-up β€” the algorithm itself works on 26 schools β€” and most of the start-up is Python: --no-python cut each run to about a third. It is negligible for a hundred files and significant for a hundred thousand tiny ones, where one algorithm run over a merged input, or PyQGIS, is the better shape.

A thin wrapper around the Toolbox

qgis_process is a thin wrapper around the same Processing framework the Toolbox uses. It starts a QgsApplication, initialises the providers and the algorithm registry, resolves your parameters against the algorithm's declared inputs, runs it, writes the outputs, and shuts down in the correct order. That last part is not a small thing: managing the QGIS lifecycle by hand is where a large share of PyQGIS crashes come from, and the CLI does it the same way on every invocation.

qgis_process compared with a PyQGIS script. two tools, one engineqgis_processβœ“no lifecycle to manageβœ“a crash costs one runβœ“parallel with xargs or makeβœ“runs models built in the GUIPyQGIS scriptβœ“control flow between stepsβœ“in-memory intermediatesβœ“feature-level custom logicβœ“project and layer-tree APIsMany good pipelines use both: Python decides, the CLI executes spatialworkflow.io
Two tools for the same engine β€” pick by how much control the task actually needs.

Process isolation is the second structural advantage. Each run is its own process with its own memory, so a segfault inside a driver costs you one file rather than an overnight job, and memory is reclaimed completely between invocations. That is also what makes shell-level parallelism honest: example 1's four qgis_process workers really ran side by side, with none of the pickling and thread-safety constraints that make PyQGIS awkward in a process pool.

The trade-off is granularity. qgis_process runs one algorithm; anything conditional, iterative or stateful has to live in the shell or in a calling Python program. Intermediate results must be written to disk between steps, which costs I/O that an in-process PyQGIS script would avoid. And there is the start-up cost example 5 measured, paid on every call.

That suggests a clean division. Reach for qgis_process when the task is "run this algorithm, or this model, over these inputs": batch conversions, scheduled model runs, CI steps, Makefile pipelines. Reach for PyQGIS when you need control flow between steps, in-memory intermediates, custom feature-level logic, or the project and layer-tree APIs. Many good pipelines use both β€” a Python driver that decides what to run, and qgis_process subprocesses that do the running.

Separators, extent order, enums and exit codes

  • -- is mandatory: parameters after it belong to the algorithm. Before it, qgis_process reads its own options.
  • Extent order is xmin,xmax,ymin,ymax: different from the minx,miny,maxx,maxy used by GeoPandas and GDAL bounds.
  • Enums are integers: qgis_process help <id> lists the mapping. Passing the label was refused with "Incorrect parameter value" and a non-zero exit.
  • Exit codes: 0 for success, non-zero on failure β€” but also check the output, since an algorithm can succeed and produce zero features.
  • Model outputs are child:OUTPUT: read them with help <file.model3> before writing the command.
  • Plugin algorithms need Python: --no-python is faster but hides qgis:, grass:, model:, script: and plugin providers. Drop the flag when you need them.
  • The project flag moved: it is --PROJECT_PATH= after the --, not a --project option before it.

FAQ

What is qgis_process and where do I find it?

It is the command-line runner that ships with QGIS, in the same bin folder as the qgis executable β€” /usr/bin/qgis_process on Linux and in the qgis/qgis image. On Windows it is a .bat file; on macOS it is inside QGIS.app/Contents/MacOS/bin.

How do I find an algorithm's parameter names?

qgis_process help <algorithm_id> prints every parameter, its type, and the integer values behind each enum β€” for your installed version, which is what makes it authoritative.

Why do I need -- before the parameters?

It separates qgis_process's own flags from the algorithm's parameters. Without it, INPUT=... is parsed as a flag and the run fails.

Can I run a model I built in the Graphical Modeler?

Yes. Models in the user profile appear as model:name in qgis_process list, and a .model3 file can be passed directly by path β€” which is what you want in CI. Read its parameters with qgis_process help file.model3 first: outputs are named after the child algorithm, such as buf:OUTPUT.

How do I read the result in a script?

Add --json and parse the output with jq or json.loads. The object contains the results dictionary, so .results.OUTPUT gives you the path that was written.

Should I use qgis_process or write PyQGIS?

qgis_process for single-algorithm runs, batches and scheduled model runs β€” no lifecycle to manage and crashes stay contained. PyQGIS when you need control flow, in-memory intermediates, or the project and layer APIs.

Does it work headless in Docker?

Yes. Use a qgis/qgis image, set QT_QPA_PLATFORM=offscreen, and mount your data as a volume. The image warned that XDG_RUNTIME_DIR was not set and then ran normally, so setting it only silences the warning.