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:
qgis_process listto find the algorithm idqgis_process help <id>for the exact parameter names and enum valuesqgis_process run <id> -- KEY=VALUE β¦to execute it- add
--jsonwhen a script has to read the result - set
QT_QPA_PLATFORM=offscreenon 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
-- is what separates the tool's own flags from the algorithm's.Discover, read parameters, run, chain
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.
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
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.
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_processreads its own options.- Extent order is
xmin,xmax,ymin,ymax: different from theminx,miny,maxx,maxyused 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 withhelp <file.model3>before writing the command. - Plugin algorithms need Python:
--no-pythonis faster but hidesqgis:,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--projectoption before it.
Internal links
- How to Run QGIS Processing Algorithms from Python
- How to Build a QGIS Processing Model and Run It from Python
- How to Run a PyQGIS Script Headless Without Opening QGIS
- QGIS Processing "Algorithm Not Found" Error in Python: How to Fix It
- PyQGIS Script Crashes with a Segmentation Fault: How to Fix It
- qt.qpa.plugin: Could Not Load the Qt Platform Plugin xcb in PyQGIS
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.