How to Build a QGIS Processing Model and Run It from Python
On this page
A Processing model is a workflow drawn as a graph: inputs at the top, algorithms in the middle, outputs at the bottom, saved as a single file. It is the most under-used part of QGIS for automation, because most people meet it as a GUI toy and never notice that a saved model is a first-class algorithm β it appears in the toolbox, it can be nested inside another model, it runs from qgis_process on a server, and it can be called from Python with the same processing.run() you already use. That last property is the interesting one: it lets the person who owns the workflow edit it without touching your script.
Problem statement
You have a multi-step QGIS workflow and you want it to run unattended, but you also want it to stay editable by someone who is not a programmer. The friction:
- The workflow lives in someone's head, reconstructed from memory each month with slightly different parameters.
- A Python script is opaque to the analyst who actually owns the methodology, so every change routes through you.
- The model you built runs in the desktop but "is not found" from a script β a profile problem nobody explains.
- You cannot tell what a model's parameter names are, so
processing.run("model:clean_parcels", {...})fails on a key you guessed. - A model that works for you fails for the service account, because models live in a user profile that account does not have.
- Version control β the model sits in a hidden folder, so nobody diffs it or reviews changes.
A model built once in the modeler can live beside the code, load by path, run from Python with parameter names read from the model itself, and be reviewed in a pull request. Everything below was run headless under QGIS 4.2 on Rutland's Land Registry parcels and wards; the model itself is built in code by the setup, with the names the Graphical Modeler would have given it.
Quick answer
A model saved as .model3 can be loaded by path and passed straight to processing.run β no profile, no installation step.
from qgis.core import QgsProcessingModelAlgorithm
model = QgsProcessingModelAlgorithm()
if not model.fromFile("models/clean_parcels.model3"):
raise SystemExit("could not read model file")
# Never guess the parameter names - ask:
for p in model.parameterDefinitions():
print(f"{p.name():18} {p.type():10} {p.description()}")
result = processing.run(model, {
"input_layer": "data/rutland.gpkg|layername=parcels",
"ward": "data/oakham.gpkg",
"buffer_distance": 25,
"clean_parcels": "out/parcels_clean.gpkg",
})
print(result["clean_parcels"], QgsVectorLayer(result["clean_parcels"], "c", "ogr").featureCount(), "features")
buffer_distance distance Buffer distance
input_layer vector Input layer
ward vector Ward
clean_parcels sink Clean parcels
out/parcels_clean.gpkg 1765 features
If the model is installed in the active QGIS profile you can also call it by id β processing.run("model:clean_parcels", {...}) β but loading by path is the version that works the same on your laptop and on the server. Note what processing.run does not accept: a path to the .model3 file as a string. It looks that up as an algorithm id and reports it not found; load the file into a QgsProcessingModelAlgorithm first.
When to model, how to build it, how to run it
Know when a model is the right tool
Reach for a model when the workflow is a fixed sequence of existing algorithms, when the person who owns the methodology is not a programmer, and when you want the thing to appear in the toolbox for interactive use as well as scripted use.
Reach for a script when you need conditionals, loops over files, error handling per item, or any logic that is not "run these algorithms in this order". The modeler has no general if and no for over a list of files; attempts to fake them with expression-driven parameters get unreadable fast.
The productive answer is usually both: a model for the analytical core, a Python script for the batch loop, the error handling, and the reporting around it.
Build the model in the Graphical Modeler
Processing β Graphical Modeler (or Ctrl+Alt+G). The workflow:
- Give the model a name and a group in the model properties panel β the name becomes part of its algorithm id.
- Add inputs from the Inputs panel:
Vector Layer,Number,Distance,Raster Layer,Boolean. Each becomes a parameter of the resulting algorithm. - Add algorithms from the Algorithms panel. For each, wire its
Input layerto either a model input or the output of an earlier algorithm. - For each algorithm output you want to keep, type a name in the output field β that promotes it to a model output. Leave it blank and it stays an internal intermediate.
- Save as
.model3into your repository, not into the default profile folder.
That last point is the one that turns a model from a personal convenience into infrastructure. models/clean_parcels.model3 next to job.py is versioned, reviewable, and deployed by the same git pull as the code.
A handful of algorithms never appear in the modeler's list at all:
hidden = sorted(a.id() for a in QgsApplication.processingRegistry().algorithms()
if a.flags() & Qgis.ProcessingAlgorithmFlag.HideFromModeler)
print(len(hidden), "of", len(QgsApplication.processingRegistry().algorithms()), "algorithms are hidden from the modeler:")
print(hidden)
12 of 772 algorithms are hidden from the modeler:
['native:alignrasters', 'native:rastercalc', 'native:virtualrastercalc', 'native:zonalstatistics', 'qgis:distancetonearesthublinetohub', 'qgis:distancetonearesthubpoints', 'qgis:knearestconcavehull', 'qgis:rastercalculator', 'qgis:rectanglesovalsdiamondsvariable', 'qgis:setstyleforrasterlayer', 'qgis:setstyleforvectorlayer', 'qgis:variabledistancebuffer']
If a step you need is on that list, a script algorithm that calls it can stand in as a child.
Understand what a saved model is
A .model3 file is XML: a <!DOCTYPE model> document of nested QGIS Option maps recording the model's name and group, its declared parameters, its child algorithms with their parameter bindings, and which child outputs are promoted to model outputs.
text = Path("models/clean_parcels.model3").read_text()
print(text.splitlines()[0], "|", len(text.splitlines()), "lines")
# change one value the way an analyst would in the modeler, save again, and diff
edited = QgsProcessingModelAlgorithm()
edited.fromFile("models/clean_parcels.model3")
edited.parameterDefinition("buffer_distance").setDefaultValue(30)
edited.toFile("out/clean_parcels_edited.model3")
diff = difflib.unified_diff(text.splitlines(), Path("out/clean_parcels_edited.model3").read_text().splitlines(),
lineterm="", n=0)
print("\n".join(line.strip() for line in diff if line[:1] in "+-" and line[:3] not in ("+++", "---")))
<!DOCTYPE model> | 279 lines
- <Option name="default" type="int" value="25"/>
+ <Option name="default" type="int" value="30"/>
Because it is text, git diff on a model change is noisy but readable: a buffer distance moving from 25 to 30 is one changed line, which is exactly the review you want.
The important consequence: a model is a QgsProcessingAlgorithm. Everything true of native:buffer is true of it. It has parameterDefinitions(), it validates its inputs, it reports progress through a feedback object, it can be nested as a child inside another model, and processing.run accepts it.
Find the real parameter names
This is where scripts break. The modeler generates internal parameter names from the descriptions you typed, with the same function the modeler's dialog calls:
for description in ("Input layer", "Buffer distance", "Clean parcels", "Buffer distance (m)"):
print(f"{description!r:24} -> {QgsProcessingModelAlgorithm.safeName(description).lower()!r}")
print("outputs:", [(o.name(), o.description()) for o in model.outputDefinitions()])
'Input layer' -> 'input_layer'
'Buffer distance' -> 'buffer_distance'
'Clean parcels' -> 'clean_parcels'
'Buffer distance (m)' -> 'buffer_distance_m'
outputs: [('clean_parcels', 'Clean parcels')]
So "Input layer" becomes input_layer, and a later edit that adds "(m)" to a description renames the parameter under your script. Outputs follow the same rule when they have a description; an output promoted without one is named after its child instead β native:clip_1:OUTPUT style, the child's id and the output's name β so read the names rather than predicting them.
qgis_process help models/clean_parcels.model3 prints the same signature from the shell. processing.algorithmHelp() does not help here: it only takes an id, and raised a TypeError when handed the loaded model.
Run it, from a path or from the registry
By path β portable, and the right default for automation:
def load_model(path):
model = QgsProcessingModelAlgorithm()
if not model.fromFile(str(path)):
raise FileNotFoundError(f"not a readable model: {path}")
return model
params = {"input_layer": "data/rutland.gpkg|layername=parcels", "ward": "data/oakham.gpkg",
"buffer_distance": 25, "clean_parcels": "TEMPORARY_OUTPUT"}
print("by path:", processing.run(load_model("models/clean_parcels.model3"), params)["clean_parcels"].featureCount())
by path: 1765
By id β convenient interactively, once the model is in the active profile's processing/models folder:
installed = profile / "processing/models"
installed.mkdir(parents=True, exist_ok=True)
shutil.copy("models/clean_parcels.model3", installed)
QgsApplication.processingRegistry().providerById("model").refreshAlgorithms()
for alg in QgsApplication.processingRegistry().providerById("model").algorithms():
print(alg.id(), "-", alg.displayName())
print("by id: ", processing.run("model:clean_parcels", params)["clean_parcels"].featureCount())
model:clean_parcels - clean_parcels
by id: 1765
The id is model: plus the model's internal name. List what is actually registered rather than assuming.
Make it work for the service account
A model in your profile does not exist for the user that cron runs as β and a standalone script does not even use QGIS Desktop's profile when it runs as you: QgsApplication([], False) gets a profile of its own under ~/.local/share/profiles/, as the extension-points guide measured. Two fixes, in order of preference:
- Load by path, as above. Nothing to install; the model ships with the code.
- Point QGIS at a profile you control: set
QGIS_CUSTOM_CONFIG_PATH=/srv/gisbefore the application starts, and place the models under/srv/gis/profiles/default/processing/models/. This page does exactly that with a folder in its working directory. Version that directory too.
This is the same class of problem as everything else in running PyQGIS headless: the desktop sets up an environment that a scheduled job does not inherit.
Running a Rutland parcels model from Python
The data used on this page
HM Land Registry's INSPIRE Index Polygons for Rutland (Β© Crown copyright and database rights, HM Land Registry; OGL) and the county's wards from the ONS December 2024 boundaries, with Oakham North East as the clip boundary. The model β fix geometries, buffer by a distance, clip to a ward β is built in code with the names the Graphical Modeler gives, and saved to models/. QGIS runs with a profile inside the working directory.
import difflib
import os
import shutil
import subprocess
import sys
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.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",
layerName="parcels", SQLStatement="SELECT INSPIREID FROM PREDEFINED")
Path("data/wards.geojson").write_bytes(session.get(
"https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
params={"where": "LAD24CD='E06000017'", "outFields": "WD24NM", "outSR": 27700, "f": "geojson"},
timeout=120).content)
gdal.VectorTranslate("data/oakham.gpkg", "data/wards.geojson", layerName="ward",
where="WD24NM = 'Oakham North East'")
os.environ["QGIS_CUSTOM_CONFIG_PATH"] = str(Path("config").resolve())
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.append("/usr/share/qgis/python/plugins")
from qgis.core import (Qgis, QgsApplication, QgsProcessingModelAlgorithm, QgsProcessingModelChildAlgorithm,
QgsProcessingModelChildParameterSource as Source, QgsProcessingModelOutput,
QgsProcessingModelParameter, QgsProcessingParameterDistance,
QgsProcessingParameterVectorLayer, QgsProject, QgsVectorLayer)
qgs = QgsApplication([], False)
qgs.initQgis()
profile = Path(QgsApplication.qgisSettingsDirPath())
from processing.core.Processing import Processing
import processing
Processing.initialize()
def gui_name(description):
"""The name the Graphical Modeler derives from a description (ModelerParameterDefinitionDialog.py)."""
return QgsProcessingModelAlgorithm.safeName(description).lower()
model = QgsProcessingModelAlgorithm("clean_parcels", "Rutland")
for definition in (QgsProcessingParameterVectorLayer(gui_name("Input layer"), "Input layer"),
QgsProcessingParameterVectorLayer(gui_name("Ward"), "Ward"),
QgsProcessingParameterDistance(gui_name("Buffer distance"), "Buffer distance", 25, "input_layer")):
model.addModelParameter(definition, QgsProcessingModelParameter(definition.name()))
fix_id = model.addChildAlgorithm(QgsProcessingModelChildAlgorithm("native:fixgeometries"))
buffer_id = model.addChildAlgorithm(QgsProcessingModelChildAlgorithm("native:buffer"))
clip_id = model.addChildAlgorithm(QgsProcessingModelChildAlgorithm("native:clip"))
wiring = {fix_id: {"INPUT": [Source.fromModelParameter("input_layer")]},
buffer_id: {"INPUT": [Source.fromChildOutput(fix_id, "OUTPUT")],
"DISTANCE": [Source.fromModelParameter("buffer_distance")]},
clip_id: {"INPUT": [Source.fromChildOutput(buffer_id, "OUTPUT")],
"OVERLAY": [Source.fromModelParameter("ward")]}}
for child_id, sources in wiring.items():
child = model.childAlgorithm(child_id)
for name, value in sources.items():
child.addParameterSources(name, value)
if child_id == clip_id:
output = QgsProcessingModelOutput(gui_name("Clean parcels"), "Clean parcels")
output.setChildId(clip_id)
output.setChildOutputName("OUTPUT")
child.setModelOutputs({output.name(): output})
model.setChildAlgorithm(child)
model.updateDestinationParameters()
model.toFile("models/clean_parcels.model3")
print("QGIS", Qgis.QGIS_VERSION.split("-")[0], "| children:", list(wiring), "| profile:", profile.relative_to(Path.cwd()))
QGIS 4.2.2 | children: ['native:fixgeometries_1', 'native:buffer_1', 'native:clip_1'] | profile: config/profiles/default
Example 1: A wrapper that validates before running
from qgis.core import QgsProcessingContext
MODEL_DIR = Path("models")
def load(name: str) -> QgsProcessingModelAlgorithm:
path = MODEL_DIR / f"{name}.model3"
model = QgsProcessingModelAlgorithm()
if not model.fromFile(str(path)):
raise FileNotFoundError(f"could not load model: {path}")
return model
def run(name: str, params: dict, feedback=None):
model = load(name)
known = {p.name() for p in model.parameterDefinitions()}
unknown = set(params) - known
if unknown:
raise KeyError(f"unknown parameters {sorted(unknown)}; model accepts {sorted(known)}")
ok, message = model.checkParameterValues(params, QgsProcessingContext())
if not ok:
raise ValueError(f"invalid parameters for {name}: {message}")
return processing.run(model, params, feedback=feedback)
print(run("clean_parcels", params)["clean_parcels"].featureCount(), "parcels")
for bad in ({**params, "bufer_distance": 30}, {k: v for k, v in params.items() if k != "input_layer"}):
try:
run("clean_parcels", bad)
except (KeyError, ValueError) as exc:
print(type(exc).__name__ + ":", str(exc)[:100])
1765 parcels
KeyError: "unknown parameters ['bufer_distance']; model accepts ['buffer_distance', 'clean_parcels', 'input_la
ValueError: invalid parameters for clean_parcels: Incorrect parameter value for input_layer
The unknown check is worth its four lines. A misspelled key is otherwise ignored in silence, and the model runs with its default β here the distance would have stayed at 25 with nobody told. checkParameterValues catches the missing layer before anything runs.
Example 2: The same model over a folder of inputs
The model supplies the analysis; Python supplies the loop, the isolation, and the report.
import csv
SRC, DST = Path("data/raw"), Path("out/processed")
SRC.mkdir(exist_ok=True)
DST.mkdir(exist_ok=True)
for part in range(3):
gdal.VectorTranslate(str(SRC / f"parcels_{part + 1}.gpkg"), "data/rutland.gpkg",
SQLStatement=f"SELECT * FROM parcels LIMIT 5000 OFFSET {part * 5000}", layerName="parcels")
(SRC / "parcels_4.gpkg").write_text("not a GeoPackage") # one broken delivery
rows = []
for src in sorted(SRC.glob("*.gpkg")):
out = DST / f"{src.stem}_clean.gpkg"
try:
result = run("clean_parcels", {"input_layer": f"{src}|layername=parcels", "ward": "data/oakham.gpkg",
"buffer_distance": 25, "clean_parcels": str(out)})
rows.append({"file": src.name, "status": "ok",
"features": QgsVectorLayer(result["clean_parcels"], "r", "ogr").featureCount()})
except Exception as exc:
rows.append({"file": src.name, "status": "failed", "error": " ".join(str(exc).split())[:60]})
with open(DST / "report.csv", "w", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=["file", "status", "features", "error"])
writer.writeheader()
writer.writerows(rows)
print(Path(DST / "report.csv").read_text())
file,status,features,error
parcels_1.gpkg,ok,0,
parcels_2.gpkg,ok,372,
parcels_3.gpkg,ok,1325,
parcels_4.gpkg,failed,,invalid parameters for clean_parcels: Incorrect parameter va
The analyst can now change the methodology inside the model and the loop is unaffected β that separation is the entire argument for this approach. The broken file failed on its own row and the other three ran.
Example 3: Progress, messages, and what the children produced
from qgis.core import QgsProcessingFeedback
class LogFeedback(QgsProcessingFeedback):
def __init__(self):
super().__init__()
self.lines, self.updates = [], 0
self.progressChanged.connect(self.count)
def count(self, _): self.updates += 1
def setProgressText(self, text): self.lines.append(("step", text))
def pushInfo(self, info): self.lines.append(("info", info))
def pushWarning(self, warning): self.lines.append(("warning", warning))
def pushDebugInfo(self, info): self.lines.append(("debug", info))
feedback = LogFeedback()
result = processing.run(load("clean_parcels"), {**params, "clean_parcels": "out/example3.gpkg"}, feedback=feedback)
print(feedback.updates, "progress updates")
for kind, line in feedback.lines:
if not line.startswith("Results:"):
print(f"{kind:8} {line[:90]}")
for child, outputs in result["CHILD_RESULTS"].items():
print(f"{child:24} -> {outputs['OUTPUT'][:40]}")
667 progress updates
warning No spatial index exists for input layer, performance will be severely degraded
debug Model processed OK. Executed 3 algorithm(s) total in 1.679 s.
native:buffer_1 -> Buffered_d6ebaaa4_80de_410a_98dc_85694c8
native:clip_1 -> out/example3.gpkg
native:fixgeometries_1 -> Fixed_geometries_6bfca35c_f774_41f9_807f
Without a feedback object, all of that goes nowhere. With one, a long run reports its progress continuously, and the messages are worth reading: native:clip warned that its input had no spatial index and performance would suffer, which is the kind of thing you only learn from the feedback. Two things it did not do headless in QGIS 4.2: name each child step as it started β no step text arrived at all, only the closing summary with its timing β and write intermediates to disk. The child results show the fix-geometries and buffer outputs as layers held in memory by the run; only the promoted output went to a file.
Example 4: Reading the model to document it
A script can generate documentation from the model and keep the docs honest. Read it through the API rather than the XML β the object is what QGIS itself will run:
def describe(path):
model = load(Path(path).stem)
print("model:", model.name(), "| group:", model.group())
print("inputs:", [f"{p.name()} ({p.type()})" for p in model.parameterDefinitions()])
for child_id in model.childAlgorithms():
child = model.childAlgorithm(child_id)
print(f" - {child.algorithmId():22} as {child_id}")
describe("models/clean_parcels.model3")
model: clean_parcels | group: Rutland
inputs: ['buffer_distance (distance)', 'input_layer (vector)', 'ward (vector)', 'clean_parcels (sink)']
- native:buffer as native:buffer_1
- native:clip as native:clip_1
- native:fixgeometries as native:fixgeometries_1
Committing that output as models/README.md means a reviewer sees what changed in a model without opening QGIS.
Example 5: Running a model from the shell
cli = subprocess.run(["qgis_process", "run", "models/clean_parcels.model3", "--",
"input_layer=data/rutland.gpkg|layername=parcels", "ward=data/oakham.gpkg",
"buffer_distance=25", "clean_parcels=out/parcels_cli.gpkg"],
capture_output=True, text=True, env=dict(os.environ))
print("exit", cli.returncode, "|", [l for l in cli.stdout.splitlines() if l.startswith("clean_parcels")][:1])
exit 0 | ['clean_parcels:\tout/parcels_cli.gpkg']
qgis_process accepts the .model3 path in place of an id β the qgis_process guide ran the same in the 3.44 image β so no Python is needed at all. It is the shortest route from a saved model to a scheduled job.
Why models are about ownership
The reason models are worth the detour is ownership. In most GIS teams the person who knows why a buffer is 25 metres is not the person who maintains the scheduler. A Python script forces those two roles through one file, so every methodological change becomes a code change with a code review and a deployment. A model splits them cleanly: the analyst edits a graph in a familiar tool and commits a .model3; the script that runs it does not change at all. The interface between the two is the model's parameter list, which is exactly the sort of thing an interface should be β small, declared, and checkable.
The second reason is that a model is not a lesser form of automation. Because it implements the same QgsProcessingAlgorithm interface as native:buffer, it inherits everything the framework provides: parameter validation, a generated dialog, progress reporting, cancellation, nesting inside other models, availability in qgis_process, and a stable id. Nothing about running one from Python is special-cased beyond loading it. That is a genuinely good piece of design, and it means the skills transfer in both directions β everything in running Processing algorithms from Python applies to models.
Where models stop is equally clear, and pretending otherwise wastes days. There is no branching, no iteration over an arbitrary list, no per-item error handling, and no way to say "if the layer has fewer than ten features, skip it". When you find yourself encoding logic into expression-driven parameters to dodge that limit, the workflow has outgrown the modeler and wants a custom script algorithm β which, usefully, can then be dropped back into a model as a child.
The last thing to internalise is the profile. QGIS keeps models, scripts, plugins, and settings in a per-user profile directory, and the desktop's convenience β "save the model and it appears in the toolbox" β becomes an obstacle the moment another account, or a standalone script, runs the code. Loading by path sidesteps the whole issue and makes the model a project artefact rather than a personal one. It also makes the model reproducible in the sense that matters for reproducible GIS workflows: the exact file that produced last month's output is in last month's commit.
Generated names and version-sensitive files
Model parameter names are generated, not chosen
You type a description; the modeler derives the name with safeName() and lower-cases it. Renaming the description in a later edit can change the parameter name and break a script that hard-codes it. Print parameterDefinitions() after any model edit, and assert the expected set at the top of your script β as example 1 does β so a rename fails loudly rather than silently defaulting.
Output parameter names depend on whether the output has a description
With a description, an output is named from it (clean_parcels); without one, from its child β buf:OUTPUT, or native:clip_1:... with a generated child id. The second form is stable only as long as that child is not deleted and re-added. Introspect outputDefinitions() rather than typing either from memory.
.model3 files are version-sensitive
A model built with QGIS 4.2 loaded and ran in the 3.44 image in the qgis_process guide, but a model that uses an algorithm the older release lacks will not β 29 native: ids in 4.2 do not exist in 3.44. Note the authoring version in the repository, pin the QGIS version in the environment that runs the job, and treat a fromFile() that returns False as a reason to check versions first.
Nested models must be resolvable
A model that uses another model as a child references it by id, so the inner model must be installed in the profile even when the outer one is loaded by path:
nested = QgsProcessingModelChildAlgorithm("model:clean_parcels")
print("resolves with the model installed in the page's profile:", nested.algorithm() is not None)
(installed / "clean_parcels.model3").unlink()
QgsApplication.processingRegistry().providerById("model").refreshAlgorithms()
print("resolves after removing it:", QgsProcessingModelChildAlgorithm("model:clean_parcels").algorithm() is not None)
resolves with the model installed in the page's profile: True
resolves after removing it: False
For automation, either flatten the workflow into one model or install the inner models into a controlled profile directory.
Not every algorithm is available in the modeler
Algorithms flagged HideFromModeler do not appear β twelve of them in QGIS 4.2, listed above. If a step you need is missing, wrap it as a script algorithm; script algorithms do appear in the modeler and can be wired in like any other child.
Intermediate outputs stay in memory
In QGIS 4.2 the children's intermediate outputs in example 3 were layers held by the run, not temporary files. For very large intermediate layers that is a memory cost rather than a disk one, and a model with a huge intermediate can outgrow RAM where a hand-written chain writing to disk would not. Measure before assuming either way.
The page's QGIS session ends here:
QgsProject.instance().clear()
qgs.exitQgis()
print("QGIS closed")
QGIS closed
Internal links
- How to Automate QGIS with Python (PyQGIS): The Complete Workflow
- How to Run QGIS Processing Algorithms from Python
- How to Write a Custom QGIS Processing Script Algorithm in Python
- How to Batch Process Layers in QGIS with PyQGIS
- How to Run a PyQGIS Script Headless Without Opening QGIS
- How to Chain GIS Processing Steps into a Reusable Pipeline
- How to Make a GIS Workflow Reproducible in Python
FAQ
Where are QGIS Processing models saved?
By default in the active user profile, under processing/models/ β for QGIS Desktop 4 on Linux, ~/.local/share/QGIS/QGIS4/profiles/default/processing/models/, with equivalents under AppData on Windows and Library/Application Support on macOS. A standalone script does not use that profile, so for automation save the .model3 into your project repository and load it by path.
How do I find a model's parameter names?
Load it and iterate model.parameterDefinitions(), printing p.name() and p.description(); do the same with outputDefinitions() for the outputs, or run qgis_process help path/to/model.model3. processing.algorithmHelp() takes only an id and raised a TypeError when handed a loaded model. The names are generated from the descriptions you typed β "Input layer" becomes input_layer β so never assume them from the labels.
Can I run a model without installing it in QGIS?
Yes, and you should. QgsProcessingModelAlgorithm().fromFile(path) reads the file directly, and processing.run() accepts the resulting object. Nothing needs to be in a profile, which means the model travels with your code and behaves identically for every user and every service account.
Why does model:my_model work in QGIS but not in my script?
Because the id resolves through the active user profile, and a standalone script has a different, usually empty, profile β even when it runs as the same user, since QgsApplication([], False) gets one of its own. Either load the model by path, or set QGIS_CUSTOM_CONFIG_PATH to a directory whose profiles/default/processing/models holds your models and is deployed with the job.
Should I use a model or a Python script?
Use a model for a fixed sequence of existing algorithms, particularly when a non-programmer owns the methodology and needs to edit it. Use a script when you need conditions, loops over files, per-item error handling, or reporting. The best arrangement is usually a model for the analysis wrapped in a script for the operations.
Can a model loop over a folder of files?
Not directly: the modeler has no concept of a file list. Put the model inside a Python loop that walks the folder, as example 2 does β see batch processing layers with PyQGIS β which also gives you per-file error isolation and a report.
How do I put a model under version control?
Save the .model3 into your repository rather than the profile folder and commit it like any other file. It is XML, so diffs are readable if noisy β a buffer distance changed from 25 to 30 is one changed line. Committing a generated summary of the model's children alongside it makes the diff friendlier for reviewers who do not open QGIS.