How to Build a QGIS Processing Model and Run It from Python

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:cleanup", {...}) 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 is a binary-feeling blob in a hidden folder, so nobody diffs it or reviews changes.

The goal: a model built once in the modeler, stored beside the code, loaded explicitly by path, run from Python with introspected parameter names, and reviewable in a pull request.

Quick answer

A model saved as .model3 can be loaded by path and passed straight to processing.run β€” no profile, no installation step.

Five steps to run a saved Processing model from Python: load the file, inspect parameters, build the dictionary, run, read results.
Loading by path is what makes a model portable β€” the profile folder never enters the picture.
from qgis.core import QgsProcessingModelAlgorithm
import processing

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(p.name(), "-", p.description(), "-", p.type())

result = processing.run(model, {
    "inputlayer": "data/parcels.gpkg|layername=parcels",
    "buffer_distance": 25,
    "native:clip_1:clipped": "out/parcels_clean.gpkg",
})
print(result)

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.

Step-by-step solution

Know when a model is the right tool

Graphical model versus Python script β€” what each one is better at.
Models win on ownership and legibility; scripts win the moment you need a condition or a loop.

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 if and no for; 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:

  1. Give the model a name and a group in the model properties panel β€” the name becomes part of its algorithm id.
  2. Add inputs from the Inputs panel: Vector Layer, Number, Distance, Raster Layer, Boolean. Each becomes a parameter of the resulting algorithm.
  3. Add algorithms from the Algorithms panel. For each, wire its Input layer to either a model input or the output of an earlier algorithm.
  4. 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.
  5. Save as .model3 into 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.

Understand what a saved model is

The anatomy of a Processing model: declared inputs, chained child algorithms, promoted outputs.
Inputs and promoted outputs form the model's public signature; the child algorithms are its implementation.

A .model3 file is JSON. It records the model's name and group, its declared parameters, a list of child algorithms each with its id and parameter bindings, and which child outputs are promoted to model outputs. Because it is JSON, git diff on a model change is noisy but readable β€” you can see that a buffer distance moved from 25 to 30, 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, and the transformation is not something to guess at. Ask the model:

for p in model.parameterDefinitions():
    print(f"{p.name():<24} {p.type():<16} {p.description()}")

for o in model.outputDefinitions():
    print("output:", o.name(), "-", o.description())

Output parameter names for promoted child outputs often look like native:clip_1:clipped β€” the child algorithm id, its instance number, and the output name. Unusual, but stable, and there is no reason to memorise it when one loop prints it.

Two other routes to the same information: processing.algorithmHelp(model) prints the whole signature in one call, and if the model is installed in your profile, qgis_process help model:clean_parcels prints it from the shell.

Run it, from a path or from the registry

By path β€” portable, and the right default for automation:

from qgis.core import QgsProcessingModelAlgorithm
import processing

def load_model(path):
    model = QgsProcessingModelAlgorithm()
    if not model.fromFile(str(path)):
        raise FileNotFoundError(f"not a readable model: {path}")
    return model

model = load_model("models/clean_parcels.model3")
result = processing.run(model, params)

By id β€” convenient interactively, once the model is in the active profile's processing/models folder:

processing.run("model:clean_parcels", params)

The id is model: plus the model's internal name. List what is actually registered rather than assuming:

from qgis.core import QgsApplication

provider = QgsApplication.processingRegistry().providerById("model")
for alg in provider.algorithms():
    print(alg.id(), "β€”", alg.displayName())

Make it work for the service account

A model in your profile does not exist for the user that cron runs as. Two fixes, in order of preference:

  1. Load by path, as above. Nothing to install; the model ships with the code.
  2. Point QGIS at a profile you control: export QGIS_CUSTOM_CONFIG_PATH=/srv/gis/profile and place the models under /srv/gis/profile/processing/models/. 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.

Code examples

Example 1: A wrapper that validates before running

"""models.py β€” load, introspect, and run Processing models by path."""
from pathlib import Path

from qgis.core import QgsProcessingModelAlgorithm, QgsProcessingContext
import processing

MODEL_DIR = Path(__file__).parent / "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 signature(model) -> dict:
    return {
        "inputs": {p.name(): p.type() for p in model.parameterDefinitions()},
        "outputs": [o.name() for o in model.outputDefinitions()],
    }

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)

The unknown check is worth its four lines. A misspelled key is otherwise ignored in silence, and the model runs with its default β€” producing a plausible, wrong answer.

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
from pathlib import Path

from models import run as run_model

SRC, DST = Path("data/raw"), Path("data/processed")
rows = []

for src in sorted(SRC.glob("*.gpkg")):
    out = DST / f"{src.stem}_clean.gpkg"
    try:
        run_model("clean_parcels", {
            "inputlayer": str(src),
            "buffer_distance": 25,
            "native:clip_1:clipped": str(out),
        })
        rows.append({"file": src.name, "status": "ok"})
    except Exception as exc:
        rows.append({"file": src.name, "status": "failed", "error": str(exc)})

with open(DST / "report.csv", "w", newline="") as fh:
    w = csv.DictWriter(fh, fieldnames=["file", "status", "error"])
    w.writeheader()
    w.writerows(rows)

The analyst can now change the methodology inside the model and the loop is unaffected β€” that separation is the entire argument for this approach.

Example 3: Progress and cancellation

Models report progress per child algorithm, so a long run can say where it is.

import logging
from qgis.core import QgsProcessingFeedback

log = logging.getLogger("model")

class LogFeedback(QgsProcessingFeedback):
    def setProgressText(self, text):  log.info("%s", text)
    def pushInfo(self, info):         log.info("%s", info)
    def pushWarning(self, warning):   log.warning("%s", warning)
    def reportError(self, error, fatalError=False):
        log.error("%s", error)

run_model("clean_parcels", params, feedback=LogFeedback())

Without a feedback object, a model with eight children is a single opaque wait. With one, the log names each step as it starts β€” which is also how you find out that step six is 90% of the runtime.

Example 4: Reading the model to document it

Because the file is JSON, a script can generate documentation from it and keep the docs honest.

import json
from pathlib import Path

def describe(path):
    data = json.loads(Path(path).read_text())
    model = data.get("model", data)
    print("model:", model.get("model_name") or model.get("name"))
    children = model.get("children", {})
    print(f"{len(children)} child algorithms:")
    for child in children.values():
        alg = child.get("algorithm_id") or child.get("alg_id")
        print("  -", alg)

describe("models/clean_parcels.model3")

Committing that output as models/README.md means a reviewer sees what changed in a model without opening QGIS. The exact key names vary a little across QGIS versions, so print the top-level keys once for the version you are on rather than trusting a schema from a blog post.

Example 5: Running a model from the shell

If the model is installed in the profile the job uses, no Python is needed at all:

qgis_process list | grep '^model:'
qgis_process help model:clean_parcels

qgis_process run model:clean_parcels -- \
  inputlayer=data/parcels.gpkg \
  buffer_distance=25 \
  'native:clip_1:clipped=out/parcels_clean.gpkg'

Recent QGIS versions also accept a path to a .model3 file in place of the id β€” check qgis_process --help for the version you are deploying, because this varies. Where it works, it is the shortest possible route from a saved model to a scheduled job.

Explanation

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. 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 verbatim to models.

Where models stop is equally clear, and pretending otherwise wastes days. There is no branching, no iteration over an arbitrary list, no error handling, and no way to say "if the layer has fewer than ten features, skip it". The modeler does support running a child algorithm iteratively over the features of an input, which covers a useful slice of the loop case, but it is not a general for. 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 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.

Edge cases or notes

Model parameter names are generated, not chosen

You type a description; QGIS derives the internal name. 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 consider asserting the expected set at the top of your script so a rename fails loudly rather than silently defaulting.

Output parameter names look strange and that is correct

native:clip_1:clipped is the promoted output of the first native:clip child. It is not a typo, and it is stable as long as that child is not deleted and re-added. Introspect outputDefinitions() rather than typing it from memory.

.model3 files are version-sensitive

A model saved in QGIS 3.34 may not load in 3.22. Note the authoring version in the repository, pin the QGIS version in the environment that runs the job, and prefer the LTR release for both. A failed fromFile() returning False with no explanation is very often a version gap.

Nested models must be resolvable

A model that uses another model as a child references it by id, which means the inner model must be installed in the profile even when the outer one is loaded by path. 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 whose parameters cannot be described declaratively β€” a handful of interactive tools β€” do not appear. 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 still hit disk

By default, a model writes each child's output to a temporary file rather than streaming in memory. For very large intermediate layers this is a real cost, and a hand-written chain using TEMPORARY_OUTPUT can be meaningfully faster. Measure before assuming either way.

FAQ

Where are QGIS Processing models saved?

By default in the active user profile, under processing/models/ β€” on Linux that is ~/.local/share/QGIS/QGIS3/profiles/default/processing/models/, with equivalents under AppData on Windows and Library/Application Support on macOS. For automation, do not rely on that location: 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. processing.algorithmHelp(model) prints the whole signature in one call. The names are generated from the descriptions you typed in the modeler, 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 β€” especially one running as another user β€” has a different, usually empty, profile. Either load the model by path, or set QGIS_CUSTOM_CONFIG_PATH to a profile directory that contains 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 can iterate a child algorithm over the features of an input layer, but it has no concept of a file list. Put the model inside a Python loop that walks the folder β€” 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 JSON, so diffs are readable if noisy β€” a changed buffer distance is visible in review. Committing a generated summary of the model's children alongside it makes the diff friendlier for reviewers who do not open QGIS.