How to Automate QGIS from the Command Line with qgis_process

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 twenty lines of ceremony before the actual work:

QgsApplication.setPrefixPath("/usr", True)
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 segfaults 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 cleanly β€” every time, in its own process.

qgis_process run native:buffer -- INPUT=data/parcels.gpkg DISTANCE=25 OUTPUT=data/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, parallel, 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

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
export QT_QPA_PLATFORM=offscreen

qgis_process list | grep -i buffer
qgis_process help native:buffer

qgis_process run native:buffer -- \
  INPUT="data/raw/parcels.gpkg|layername=parcels" \
  DISTANCE=25 \
  SEGMENTS=8 \
  DISSOLVE=false \
  OUTPUT="data/out/parcels_buffered.gpkg"

echo "exit=$?"

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

Anatomy of a qgis_process command

Anatomy of a qgis_process command: binary, command, algorithm id, separator, parameters.
Five parts β€” the `--` is what separates the tool's own flags from the algorithm's.

Step-by-step solution

Vertical steps: set environment, discover algorithm, inspect parameters, run, check exit code, chain.
Six steps β€” the middle two remove all the guessing about parameter names.

Check it is installed and headless-ready

which qgis_process
qgis_process --version

# on a server with no display
export QT_QPA_PLATFORM=offscreen
export XDG_RUNTIME_DIR=/tmp/runtime-qgis
mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR"

It ships with QGIS on all platforms. On Windows it is qgis_process-qgis-ltr.bat in the bin folder; on macOS it is inside QGIS.app/Contents/MacOS/bin.

In Docker it is already on the path:

docker run --rm -v "$PWD/data:/data" qgis/qgis:release-3_40 \
  qgis_process run native:buffer -- INPUT=/data/parcels.gpkg DISTANCE=25 OUTPUT=/data/out/buf.gpkg

Discover the algorithm

qgis_process list                      # every provider, every algorithm
qgis_process list | grep -i dissolve
qgis_process list | sed -n '/^native:/p' | head -30

The output is id followed by the display name, grouped by provider β€” which makes it the fastest way to confirm whether GRASS or SAGA are actually available in this installation.

Read the parameters before guessing

qgis_process help native:buffer
native:buffer (Buffer)
----------------------------------------
Arguments
---------
INPUT: Input layer
	Argument type:	source
	Acceptable values:
		- Path to a vector layer
DISTANCE: Distance
	Argument type:	distance
SEGMENTS: Segments
	Argument type:	number
END_CAP_STYLE: End cap style
	Argument type:	enum
	Available values:
		- 0: Round
		- 1: Flat
		- 2: Square
...

This is the authoritative list for your installed version, including the integer behind each enum. It is the CLI equivalent of processing.algorithmHelp() and the reason you never have to guess END_CAP_STYLE=1.

Run it, and pass parameters correctly

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

The value conventions:

# multi-layer file: use the layer URI form
INPUT="data/atlas.gpkg|layername=parcels"

# multiple inputs: repeat the key
qgis_process run native:mergevectorlayers -- LAYERS=a.gpkg LAYERS=b.gpkg OUTPUT=merged.gpkg

# enum: the integer from `help`
END_CAP_STYLE=1

# boolean: true / false
DISSOLVE=true

# extent: xmin,xmax,ymin,ymax[CRS]
EXTENT="320000,340000,670000,690000[EPSG:27700]"

# CRS
TARGET_CRS="EPSG:27700"

# a temporary output, when you only want the summary
OUTPUT=TEMPORARY_OUTPUT

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

Read the result in a script

qgis_process run native:buffer --json -- \
  INPUT=data/raw/parcels.gpkg DISTANCE=25 OUTPUT=data/out/buf.gpkg \
  | tee /tmp/result.json | jq -r '.results.OUTPUT'
# fail the script when the algorithm fails
set -euo pipefail

output=$(qgis_process run native:buffer --json -- \
  INPUT=data/raw/parcels.gpkg DISTANCE=25 OUTPUT=data/out/buf.gpkg)

status=$(echo "$output" | jq -r '.log_messages // empty' | head -1)
path=$(echo "$output" | jq -r '.results.OUTPUT')
[ -f "$path" ] || { echo "no output produced" >&2; exit 1; }
echo "wrote $path"

--json prints a machine-readable object with the results, the algorithm details and any log messages. Combined with set -euo pipefail, a failure stops the script rather than being carried into the next step.

Useful flags:

--no-python          # skip loading Python plugins β€” faster start-up
--skip-loading-plugins
--verbose            # log everything the algorithm reports
--project=path.qgz   # run with a project loaded, for layers referenced by name

--project is what lets an algorithm resolve layers, variables and relations defined in a .qgz β€” necessary for models that reference project layers.

Chain algorithms in a shell pipeline

#!/usr/bin/env bash
set -euo pipefail
export QT_QPA_PLATFORM=offscreen

SRC="data/raw/parcels.gpkg|layername=parcels"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT

qgis_process run native:fixgeometries -- \
  INPUT="$SRC" OUTPUT="$TMP/fixed.gpkg"

qgis_process run native:reprojectlayer -- \
  INPUT="$TMP/fixed.gpkg" TARGET_CRS="EPSG:27700" OUTPUT="$TMP/projected.gpkg"

qgis_process run native:buffer -- \
  INPUT="$TMP/projected.gpkg" DISTANCE=25 DISSOLVE=false OUTPUT="$TMP/buffered.gpkg"

qgis_process run native:dissolve -- \
  INPUT="$TMP/buffered.gpkg" FIELD=class OUTPUT="data/out/parcels_zones.gpkg"

echo "wrote data/out/parcels_zones.gpkg"

Each step is a separate process, so a crash in one is contained and the shell's set -e stops the chain immediately.

Run a model built in the GUI

# models saved in the profile appear in the list with a model: prefix
qgis_process list | grep '^model:'
qgis_process help model:parcel_cleanup

qgis_process run model:parcel_cleanup -- \
  INPUT=data/raw/parcels.gpkg \
  native:buffer_1:OUTPUT=data/out/cleaned.gpkg

Or run a .model3 file directly, which is what you want in CI where no profile exists:

qgis_process run models/parcel_cleanup.model3 -- \
  INPUT=data/raw/parcels.gpkg OUTPUT=data/out/cleaned.gpkg

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

Code examples

Example 1: batch a folder with find and parallel

#!/usr/bin/env bash
set -euo pipefail
export QT_QPA_PLATFORM=offscreen

SRC=data/raw
OUT=data/out
mkdir -p "$OUT"

process_one() {
  local shp="$1"
  local name; name=$(basename "${shp%.*}")
  qgis_process run native:buffer --no-python -- \
      INPUT="$shp" DISTANCE=25 DISSOLVE=false \
      OUTPUT="data/out/${name}_buffered.gpkg" \
    && echo "ok   $name" \
    || echo "FAIL $name" >&2
}
export -f process_one

find "$SRC" -name '*.shp' -print0 \
  | parallel -0 -j 4 --bar process_one {}

Because each invocation is its own process, four at a time is genuinely parallel β€” and one bad file kills only its own worker. --no-python shaves start-up time when no plugin algorithms are needed.

Example 2: drive it from Python without initialising QGIS

"""Run QGIS algorithms as subprocesses β€” no PyQGIS in this interpreter at all."""
import json, os, subprocess
from pathlib import Path

ENV = {**os.environ, "QT_QPA_PLATFORM": "offscreen"}

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}): "
                           f"{proc.stderr.strip()[-800:]}")
    try:
        return json.loads(proc.stdout)
    except json.JSONDecodeError:
        return {"raw": proc.stdout}

result = qgis_run("native:buffer", {
    "INPUT": "data/raw/parcels.gpkg|layername=parcels",
    "DISTANCE": 25,
    "DISSOLVE": False,
    "OUTPUT": "data/out/buffered.gpkg",
})
print(result["results"]["OUTPUT"])

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

export QT_QPA_PLATFORM := offscreen

RAW  := data/raw/parcels.gpkg
FIX  := build/fixed.gpkg
PROJ := build/projected.gpkg
OUT  := data/out/parcels_zones.gpkg

.PHONY: all clean
all: $(OUT)

build:
	mkdir -p build data/out

$(FIX): $(RAW) | build
	qgis_process run native:fixgeometries -- INPUT=$< OUTPUT=$@

$(PROJ): $(FIX)
	qgis_process run native:reprojectlayer -- INPUT=$< TARGET_CRS=EPSG:27700 OUTPUT=$@

$(OUT): $(PROJ)
	qgis_process run native:buffer -- INPUT=$< DISTANCE=25 DISSOLVE=false OUTPUT=build/buf.gpkg
	qgis_process run native:dissolve -- INPUT=build/buf.gpkg FIELD=class OUTPUT=$@

clean:
	rm -rf build data/out

make gives you incremental rebuilds for free: change the input and only the affected steps re-run.

Example 4: use it as a CI step

# .github/workflows/geoprocess.yml
jobs:
  process:
    runs-on: ubuntu-latest
    container: qgis/qgis:release-3_40
    env:
      QT_QPA_PLATFORM: offscreen
      XDG_RUNTIME_DIR: /tmp/runtime-qgis
    steps:
      - uses: actions/checkout@v4
      - run: mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR"

      - 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/parcel_cleanup.model3 --json -- \
            INPUT=data/raw/parcels.gpkg \
            OUTPUT=data/out/cleaned.gpkg | tee result.json

      - name: Check the output
        run: |
          python3 -c "
          import json, sys
          from osgeo import ogr
          ds = ogr.Open('data/out/cleaned.gpkg')
          n = ds.GetLayer(0).GetFeatureCount()
          print(f'{n} features')
          sys.exit(0 if n > 0 else 1)"

      - uses: actions/upload-artifact@v4
        with: { name: cleaned, path: data/out/cleaned.gpkg }

Explanation

qgis_process is a thin, well-behaved 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 gets it right on every invocation.

Panels comparing qgis_process and a PyQGIS script across setup, crash isolation, control and composition.
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: four qgis_process commands under parallel -j 4 really do use four cores, with none of the pickling and thread-safety constraints that make PyQGIS unusable 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 a start-up cost of a second or two per invocation β€” negligible for 100 files, significant for 100,000 tiny ones.

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.

Edge cases or notes

  • -- is mandatory: Parameters after it belong to the algorithm. Without it, qgis_process tries to interpret them as its own flags.
  • 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 silently fails or picks the default.
  • Exit codes: 0 for success, non-zero on failure β€” but always check the output file exists too, since an algorithm can succeed and produce zero features.
  • Start-up cost per call: Roughly one to two seconds. For very many tiny inputs, batch them into one algorithm run or use PyQGIS.
  • Plugin algorithms need Python: --no-python is faster but hides model:, script: and plugin providers. Drop the flag when you need them.
  • Windows and macOS paths: The executable is qgis_process-qgis.bat on Windows and lives inside the app bundle on macOS; add it to PATH before scripting.

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

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 a writable XDG_RUNTIME_DIR, and mount your data as a volume.