How to Run QGIS Processing Algorithms from Python
On this page
The Processing toolbox is the best part of QGIS and the least documented from Python. Behind every dialog is a string id and a dictionary, and once you can produce those two things reliably you can script anything the toolbox can do β including the GDAL and GRASS algorithms that arrive with it. The trick is not memorising parameters. It is knowing the four places QGIS will tell you what they are.
Problem statement
You want to replace a sequence of toolbox clicks with a script, and you hit the same wall each time:
- You do not know the algorithm id. The dialog says "Buffer"; the call needs
native:buffer, and there is alsogdal:buffervectors,grass:v.bufferand aqgis:variabledistancebuffer. - You do not know the parameter names.
DISTANCEorBUFFER_DISTANCE? A wrong key is silently ignored β the algorithm runs with its default. - The algorithm "is not found".
processing.runraisesQgsProcessingExceptionin a standalone script that works fine in the console. - The output is not what you expected. A layer object where you wanted a path, a path where you wanted a layer, or a whole GeoPackage replaced when you meant to add one table to it.
- Errors are invisible. A run half-completes and the reason sits in a log panel you cannot see from a scheduled job.
Every one is solved by a call copied from a real run, checked before it executes, chained into the next algorithm, and logged when it fails. Everything below runs headless in QGIS 4.2 on real data for Rutland: 21,253 HM Land Registry parcels, 5,513 OpenStreetMap buildings, the Oakham North East ward, and a Copernicus 30 m elevation tile.
Quick answer
Every Processing call has the same three parts β an id, a parameter dictionary, and a results dictionary.
result = processing.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_buffer.gpkg",
})
print(result["OUTPUT"], "|", QgsVectorLayer(result["OUTPUT"], "b", "ogr").featureCount(), "buffered parcels")
out/parcels_buffer.gpkg | 21253 buffered parcels
To find the id and the parameter names for any algorithm, run it once from the toolbox and read Processing β History, or ask QGIS directly:
help_text = io.StringIO()
with contextlib.redirect_stdout(help_text):
processing.algorithmHelp("native:buffer")
text = help_text.getvalue()
print(text[text.index("END_CAP_STYLE"):text.index("JOIN_STYLE")].strip())
END_CAP_STYLE: End cap style
Parameter type: QgsProcessingParameterEnum
Available values:
- 0: Round
- 1: Flat
- 2: Square
Accepted data types:
- int
- str: as string representation of int, e.g. '1'
- QgsProperty
In a standalone script the framework needs initialising first, as the setup block below does β see running PyQGIS headless and the PyQGIS overview.
Find the id, pass inputs, choose outputs, read results
Find the algorithm id
Four routes, in the order you will actually use them.
The History panel. Run the algorithm once from the toolbox with real inputs, then open Processing β History. QGIS records the exact processing.run(...) call it executed, id and full parameter dictionary included. Copy, paste, edit the paths. This is the single fastest route and it is correct by construction.
algorithmHelp. It prints every parameter with its type, whether it is optional, and the accepted enum values β including the numbers behind END_CAP_STYLE and JOIN_STYLE, which are otherwise folklore.
Listing everything. When you know the word but not the id:
registry = QgsApplication.processingRegistry()
print(sorted(alg.id() for alg in registry.algorithms() if "buffer" in alg.displayName().lower()))
print("providers:", sorted(provider.id() for provider in registry.providers()))
['gdal:buffervectors', 'gdal:onesidebuffer', 'grass:r.buffer', 'grass:r.buffer.lowmem', 'grass:v.buffer', 'native:buffer', 'native:bufferbym', 'native:multiringconstantbuffer', 'native:singlesidedbuffer', 'native:taperedbuffer', 'native:wedgebuffers', 'qgis:variabledistancebuffer']
providers: ['3d', 'gdal', 'grass', 'model', 'native', 'pdal', 'project', 'qgis', 'script']
The command line. qgis_process list prints every algorithm on the system, and qgis_process help native:buffer prints the same detail as algorithmHelp without starting Python at all:
listing = subprocess.run(["qgis_process", "list"], capture_output=True, text=True, env=QGIS_ENV).stdout
print(sum(1 for line in listing.splitlines() if line.startswith("\t")), "algorithms listed by qgis_process")
431 algorithms listed by qgis_process
Understand the id namespaces
The prefix before the colon is the provider, and it tells you what you are getting.
| Prefix | Provider | Notes |
|---|---|---|
native: |
QGIS core, C++ | The bulk of the toolbox, best maintained β prefer these |
qgis: |
QGIS core, Python | 31 algorithms left in QGIS 4.2 (plots, interpolation, a few generators); older qgis: ids of ported algorithms still resolve to the native: one |
gdal: |
GDAL/OGR command wrappers | Run ogr2ogr, gdalwarp and friends β great for rasters and format conversion |
grass: |
GRASS | Powerful, but only if GRASS is installed on that machine |
pdal:, 3d: |
Point clouds, 3D tiles | Present in QGIS 4.2 builds with PDAL |
model: |
Your saved Processing models | See building a model and running it from Python |
script: |
Your own script algorithms | See writing a custom script algorithm |
Old ids keep working, which is convenient and slightly misleading:
for old in ("qgis:checkvalidity", "qgis:joinattributesbylocation", "qgis:extractbyattribute", "qgis:randompointsinsidepolygons"):
print(f"{old:32} -> {registry.algorithmById(old).id()}")
qgis:checkvalidity -> native:checkvalidity
qgis:joinattributesbylocation -> native:joinattributesbylocation
qgis:extractbyattribute -> native:extractbyattribute
qgis:randompointsinsidepolygons -> qgis:randompointsinsidepolygons
A script full of qgis: ids runs, but most of them are now native: algorithms, and the id your log records is not the one that ran. Use the resolved id. Automation should also avoid grass: where a native: equivalent exists: a grass: call in a scheduled job means the job depends on GRASS being installed on that machine.
Pass inputs in whatever form you have
The INPUT parameter is forgiving in a genuinely useful way. All of these work:
buildings_layer = QgsVectorLayer(BUILDINGS, "buildings", "ogr")
for label, source in (("a path", BUILDINGS), ("a path + layer", "data/rutland.gpkg|layername=parcels"),
("a QgsVectorLayer", buildings_layer)):
out = processing.run("native:centroids", {"INPUT": source, "ALL_PARTS": False, "OUTPUT": "TEMPORARY_OUTPUT"})["OUTPUT"]
print(f"{label:18} -> {out.featureCount():>6} centroids")
a path -> 5513 centroids
a path + layer -> 21253 centroids
a QgsVectorLayer -> 5513 centroids
The |layername= suffix matters for multi-layer containers like GeoPackage: without it you get the first layer, which is rarely what you meant and never what you meant on the day it changes.
To operate on a subset without writing an intermediate file, filter first with native:extractbyexpression β it works headlessly, where there is no selection to speak of.
Choose where the output goes
What comes back depends on the target, and on the provider:
targets = {"TEMPORARY_OUTPUT": "TEMPORARY_OUTPUT", "memory:": "memory:", "a file": "out/centroids.gpkg"}
for label, target in targets.items():
out = processing.run("native:centroids", {"INPUT": BUILDINGS, "OUTPUT": target})["OUTPUT"]
print(f"native, {label:16} -> {type(out).__name__}")
dem = processing.run("gdal:warpreproject", {"INPUT": "data/dem.tif", "TARGET_CRS": "EPSG:27700",
"TARGET_RESOLUTION": 30, "RESAMPLING": 1, "OUTPUT": "TEMPORARY_OUTPUT"})["OUTPUT"]
print(f"gdal, TEMPORARY_OUTPUT -> {type(dem).__name__} ending {Path(dem).name}")
native, TEMPORARY_OUTPUT -> QgsVectorLayer
native, memory: -> QgsVectorLayer
native, a file -> str
gdal, TEMPORARY_OUTPUT -> str ending OUTPUT.tif
"TEMPORARY_OUTPUT"β for anative:algorithm run throughprocessing.run, you get aQgsVectorLayerobject back, held in memory. Use it for every intermediate step and pass it straight into the next algorithm."memory:"β the same kind of layer;TEMPORARY_OUTPUTis the current idiom."out/result.gpkg"β a real file, and you get its path back as a string. The driver comes from the extension.gdal:algorithms run a GDAL command, so evenTEMPORARY_OUTPUTis a real file in a temporary folder, and you get its path.
Chain intermediates in memory and write once at the end. A four-step workflow that writes four GeoPackages spends time in I/O for files nobody reads.
Writing to a GeoPackage path replaces the whole file. To add a table to an existing GeoPackage, use the ogr: form:
for kind in ("house", "church"):
processing.run("native:extractbyexpression", {"INPUT": BUILDINGS, "EXPRESSION": f"\"type\" = '{kind}'",
"OUTPUT": "out/by_path.gpkg"})
processing.run("native:extractbyexpression", {"INPUT": BUILDINGS, "EXPRESSION": f"\"type\" = '{kind}'",
"OUTPUT": f"ogr:dbname='out/by_table.gpkg' table=\"{kind}\" (geom)"})
for name in ("out/by_path.gpkg", "out/by_table.gpkg"):
source = ogr.Open(name)
print(name, [f"{layer.GetName()}: {layer.GetFeatureCount()}" for layer in source])
source = None
out/by_path.gpkg ['by_path: 51']
out/by_table.gpkg ['house: 1131', 'church: 51']
The second write to by_path.gpkg threw away the 1,131 houses and left only the churches, in a layer named after the file. The ogr: table form added a second table beside the first.
Read the results dictionary properly
processing.run returns a dict, and the key is the output parameter name β usually but not always OUTPUT.
joined = processing.run("native:joinattributesbylocation", {
"INPUT": BUILDINGS, "JOIN": "data/wards.geojson", "PREDICATE": [0], "JOIN_FIELDS": ["WD24NM"],
"METHOD": 1, "DISCARD_NONMATCHING": False, "OUTPUT": "TEMPORARY_OUTPUT"})
print(sorted(joined), "| joined:", joined["JOINED_COUNT"])
snapped = processing.run("native:snappointstogrid", {"INPUT": "data/rutland.gpkg|layername=parcels",
"HSPACING": 2, "VSPACING": 2, "OUTPUT": "TEMPORARY_OUTPUT"})["OUTPUT"]
validity = processing.run("native:checkvalidity", {"INPUT_LAYER": snapped, "METHOD": 2, "VALID_OUTPUT": "TEMPORARY_OUTPUT",
"INVALID_OUTPUT": "TEMPORARY_OUTPUT", "ERROR_OUTPUT": "TEMPORARY_OUTPUT"})
print({key: value for key, value in validity.items() if key.endswith("COUNT")})
['JOINED_COUNT', 'OUTPUT'] | joined: 5222
{'ERROR_COUNT': 1400, 'INVALID_COUNT': 1400, 'VALID_COUNT': 19853}
Several algorithms return extra values worth logging. The join returned a count of buildings matched to a ward; native:checkvalidity returns three layers and three counts, and using only VALID_OUTPUT quietly discards the diagnosis. Here it found that snapping Rutland's parcels to a 2 m grid had made 1,400 of them invalid β a genuinely broken layer for the next section.
Capture feedback and errors
By default, warnings and progress go to a place a scheduled job cannot see. Pass a feedback object and route it into logging:
log = logging.getLogger("processing")
class LogFeedback(QgsProcessingFeedback):
def pushInfo(self, info): log.info(info)
def pushWarning(self, warning): log.warning(warning)
def reportError(self, error, fatalError=False):
log.error(error)
self.errors = getattr(self, "errors", [])
self.errors.append(error)
context = QgsProcessingContext()
context.setInvalidGeometryCheck(Qgis.InvalidGeometryCheck.SkipInvalid)
feedback = LogFeedback()
logging.getLogger().setLevel(logging.CRITICAL) # keep the skipped-feature lines off this page
clipped = processing.run("native:clip", {"INPUT": snapped, "OVERLAY": "data/oakham.gpkg", "OUTPUT": "TEMPORARY_OUTPUT"},
feedback=feedback, context=context)["OUTPUT"]
logging.getLogger().setLevel(logging.INFO)
print(clipped.featureCount(), "parcels clipped;", len(feedback.errors), "errors reported to the feedback, the first:")
print(" ", feedback.errors[0][:110])
try:
processing.run("native:clip", {"INPUT": snapped, "OVERLAY": "data/oakham.gpkg", "OUTPUT": "TEMPORARY_OUTPUT"})
except QgsProcessingException as exc:
print("default context, no feedback:", str(exc)[:90])
1508 parcels clipped; 146 errors reported to the feedback, the first:
Feature (9027) from βSnappedβ has invalid geometry and has been skipped. Please fix the geometry or change the
default context, no feedback: Feature (9027) from βSnappedβ has invalid geometry. Please fix the geometry or change the
With invalid-feature filtering set to skip, the clip succeeded and each of the 146 invalid parcels it met inside the ward's extent was skipped and reported β to the feedback object, and nowhere else. Without a feedback object the same run is a result with fewer features and no explanation. With the default headless context, which does no checking, the clip failed outright on the first bad parcel. Combine a logging feedback with alerting on failure and an overnight job stops being a black box.
Validate before you run
For anything long, check the parameters before spending the time:
alg = registry.algorithmById("native:buffer")
checks = {
"good": {"INPUT": BUILDINGS, "DISTANCE": 25, "OUTPUT": "TEMPORARY_OUTPUT"},
"missing input": {"INPUT": "data/nope.gpkg", "DISTANCE": 25, "OUTPUT": "TEMPORARY_OUTPUT"},
"wrong key": {"INPUT": BUILDINGS, "BUFFER_DISTANCE": 25, "OUTPUT": "TEMPORARY_OUTPUT"},
}
for label, params in checks.items():
print(f"{label:14}", alg.checkParameterValues(params, QgsProcessingContext()))
unknown = set(checks["wrong key"]) - {p.name() for p in alg.parameterDefinitions()}
print("keys the algorithm does not have:", unknown)
good (True, '')
missing input (False, 'Could not load source layer for INPUT: data/nope.gpkg not found')
wrong key (True, '')
keys the algorithm does not have: {'BUFFER_DISTANCE'}
checkParameterValues caught the missing file, but it passed the misspelt key: DISTANCE has a default of 10, so the call is valid β and would have buffered by 10 metres instead of 25. Checking the keys against parameterDefinitions() is the only thing that catches that. This is the Processing equivalent of validating pipeline inputs before the run starts.
Chaining algorithms on Rutland's parcels and buildings
The data used on this page
HM Land Registry's INSPIRE parcels for Rutland (Β© Crown copyright and database rights; OGL); Rutland's buildings from Geofabrik's OpenStreetMap extract of 1 January 2026 (Β© OpenStreetMap contributors, ODbL); the county's wards from the ONS December 2024 boundaries (OGL); and the Copernicus GLO-30 elevation tile N52 W001 (Β© DLR e.V. 2010β2014 and Β© Airbus Defence and Space GmbH 2014β2018, provided under COPERNICUS by the European Union and ESA). QGIS runs headless, with its settings kept in the working folder.
import contextlib
import io
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
import requests
from osgeo import gdal, ogr
gdal.UseExceptions()
for folder in ("data", "out"):
Path(folder).mkdir(exist_ok=True)
session = requests.Session()
downloads = {
"data/Rutland.zip": "https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip",
"data/rutland.shp.zip": "https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip",
"data/dem.tif": "https://copernicus-dem-30m.s3.amazonaws.com/Copernicus_DSM_COG_10_N52_00_W001_00_DEM/"
"Copernicus_DSM_COG_10_N52_00_W001_00_DEM.tif",
}
for target, url in downloads.items():
if not Path(target).exists():
Path(target).write_bytes(session.get(url, timeout=300).content)
if not Path("data/rutland.gpkg").exists():
gdal.VectorTranslate("data/rutland.gpkg", "/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
layerName="parcels", SQLStatement="SELECT INSPIREID FROM PREDEFINED",
options=["-oo", "DOWNLOAD_SCHEMA=NO"])
if not Path("data/wards.geojson").exists():
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'")
for old in Path("out").glob("by_*.gpkg"):
old.unlink()
BUILDINGS = "/vsizip/data/rutland.shp.zip/gis_osm_buildings_a_free_1.shp"
os.environ["QGIS_CUSTOM_CONFIG_PATH"] = str(Path("config").resolve())
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
QGIS_ENV = {**os.environ}
sys.path.append("/usr/share/qgis/python/plugins")
from qgis.core import (Qgis, QgsApplication, QgsProcessingContext, QgsProcessingException, QgsProcessingFeedback,
QgsProcessingParameterEnum, QgsProject, QgsVectorLayer)
qgs = QgsApplication([], False)
qgs.initQgis()
from processing.core.Processing import Processing
import processing
Processing.initialize()
class PrintHandler(logging.Handler): # log lines appear with the page's output
def emit(self, record):
print(self.format(record))
handler = PrintHandler()
handler.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
logging.basicConfig(level=logging.INFO, handlers=[handler], force=True)
print("QGIS", Qgis.QGIS_VERSION.split("-")[0], "| parcels:", QgsVectorLayer("data/rutland.gpkg|layername=parcels", "p", "ogr").featureCount(),
"| buildings:", QgsVectorLayer(BUILDINGS, "b", "ogr").featureCount())
QGIS 4.2.2 | parcels: 21253 | buildings: 5513
Example 1: A three-algorithm chain with no intermediate files
reprojected = processing.run("native:reprojectlayer", {
"INPUT": BUILDINGS,
"TARGET_CRS": "EPSG:27700",
"OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]
valid = processing.run("native:fixgeometries", {
"INPUT": reprojected,
"OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]
final = processing.run("native:clip", {
"INPUT": valid,
"OVERLAY": "data/oakham.gpkg|layername=ward",
"OUTPUT": "out/oakham_buildings.gpkg",
})["OUTPUT"]
print(reprojected.crs().authid(), reprojected.featureCount(), "->", valid.featureCount(), "->",
QgsVectorLayer(final, "f", "ogr").featureCount(), "buildings in Oakham North East, written to", final)
EPSG:27700 5513 -> 5513 -> 274 buildings in Oakham North East, written to out/oakham_buildings.gpkg
Reproject, repair, clip β one file written. The first two outputs are layer objects that never touch disk; only the last is a path.
Example 2: A thin wrapper that logs and times every call
alg_log = logging.getLogger("alg")
def run(alg_id, params, **kwargs):
started = time.perf_counter()
resolved = registry.algorithmById(alg_id).id()
alg_log.info("β %s%s", alg_id, "" if resolved == alg_id else f" (runs {resolved})")
try:
result = processing.run(alg_id, params, **kwargs)
except Exception as exc:
alg_log.error("β %s failed after %.1fs: %s", alg_id, time.perf_counter() - started, str(exc)[:80])
raise
alg_log.info("β %s in %.1fs", alg_id, time.perf_counter() - started)
return result
run("qgis:extractbyattribute", {"INPUT": BUILDINGS, "FIELD": "type", "OPERATOR": 0, "VALUE": "house", "OUTPUT": "TEMPORARY_OUTPUT"})
try:
run("native:clip", {"INPUT": snapped, "OVERLAY": "data/oakham.gpkg", "OUTPUT": "TEMPORARY_OUTPUT"})
except QgsProcessingException:
pass
INFO alg: β qgis:extractbyattribute (runs native:extractbyattribute)
INFO alg: β qgis:extractbyattribute in 0.0s
INFO alg: β native:clip
ERROR alg: β native:clip failed after 0.0s: Feature (9027) from βSnappedβ has invalid geometry. Please fix the geometry or c
Swap processing.run for run throughout a script and you get a timed, labelled trace of the whole workflow β including which algorithm an old id actually ran. It also gives you one place to add retries β the pattern from adding retries and timeouts drops straight in.
Example 3: Discovering enum values instead of guessing them
alg = registry.algorithmById("native:buffer")
for p in alg.parameterDefinitions():
optional = bool(p.flags() & Qgis.ProcessingParameterFlag.Optional)
line = f"{p.name():<18} {p.type():<9} {'optional' if optional else ''}"
if isinstance(p, QgsProcessingParameterEnum):
line += " " + ", ".join(f"{i}={o}" for i, o in enumerate(p.options()))
print(line)
INPUT source
DISTANCE distance
SEGMENTS number
END_CAP_STYLE enum 0=Round, 1=Flat, 2=Square
JOIN_STYLE enum 0=Round, 1=Miter, 2=Bevel
MITER_LIMIT number
DISSOLVE boolean
SEPARATE_DISJOINT boolean
OUTPUT sink
This is how you learn that END_CAP_STYLE is 0=Round, 1=Flat, 2=Square rather than copying a magic number from a forum post. Note the flag test: p.flags() is a set of flags, and testing it for truth would call a parameter with any flag at all "optional".
Example 4: Raster work through the GDAL provider
Vector algorithms are mostly native:; raster work often lives under gdal:. Reproject the elevation tile to the national grid, then summarise it per building in Oakham:
stats = registry.algorithmById("native:zonalstatisticsfb").parameterDefinition("STATISTICS").options()
print("STATISTICS options:", {i: stats[i] for i in (2, 5, 6)})
processing.run("gdal:warpreproject", {
"INPUT": "data/dem.tif",
"SOURCE_CRS": None,
"TARGET_CRS": "EPSG:27700",
"RESAMPLING": 1, # 0=nearest, 1=bilinear, 2=cubic β¦
"NODATA": -9999,
"TARGET_RESOLUTION": 30,
"TARGET_EXTENT": "475000,510000,290000,322000 [EPSG:27700]",
"OPTIONS": "COMPRESS=DEFLATE|PREDICTOR=2|TILED=YES",
"DATA_TYPE": 0,
"OUTPUT": "out/dem_bng.tif",
})
elevation = processing.run("native:zonalstatisticsfb", {
"INPUT": "out/oakham_buildings.gpkg",
"INPUT_RASTER": "out/dem_bng.tif",
"RASTER_BAND": 1,
"COLUMN_PREFIX": "elev_",
"STATISTICS": [2, 5, 6], # mean, min, max β from the list printed above
"OUTPUT": "out/oakham_elevation.gpkg",
})["OUTPUT"]
means = [f["elev_mean"] for f in QgsVectorLayer(elevation, "e", "ogr").getFeatures() if f["elev_mean"] is not None]
print(f"{len(means)} buildings, mean elevation {min(means):.0f}β{max(means):.0f} m")
STATISTICS options: {2: 'Mean', 5: 'Minimum', 6: 'Maximum'}
274 buildings, mean elevation 96β114 m
The OPTIONS string is passed to GDAL creation options verbatim β the same knobs you would use from the command line, which makes rasterio knowledge transfer directly. A 30 m elevation model is coarse beside a house, so these are the heights of the ground around each building, not of its roof.
Example 5: A config-driven step list
Because a call is just an id and a dictionary, a workflow is just a list of them β and a list is data you can put in a config file.
STEPS = [
("native:reprojectlayer", {"TARGET_CRS": "EPSG:27700"}),
("native:fixgeometries", {}),
("native:buffer", {"DISTANCE": 25, "SEGMENTS": 8, "DISSOLVE": False}),
]
def apply_all(source, steps, final_output):
current = source
for i, (alg_id, params) in enumerate(steps):
known = {p.name() for p in registry.algorithmById(alg_id).parameterDefinitions()}
if set(params) - known:
raise KeyError(f"{alg_id} has no parameter {set(params) - known}")
is_last = i == len(steps) - 1
current = processing.run(alg_id, {**params, "INPUT": current,
"OUTPUT": final_output if is_last else "TEMPORARY_OUTPUT"})["OUTPUT"]
return current
print(apply_all(BUILDINGS, STEPS, "out/buildings_buffer25.gpkg"))
out/buildings_buffer25.gpkg
That is a Processing-flavoured version of the step registry from chaining GIS processing steps, and it accepts a YAML config without further work β with the key check from the previous section built in, because a config file is exactly where a misspelt key hides.
Closing QGIS
A headless script should shut QGIS down itself; without this, the interpreter can crash on exit while tearing down Qt objects.
QgsProject.instance().clear()
qgs.exitQgis()
print("QGIS closed")
QGIS closed
A framework, not a library of functions
Processing is a framework, not a library of functions. An algorithm declares its parameters as objects β this one is a feature source, that one is a number with a minimum of zero, this one is an enum with three options β and the framework uses those declarations to build the dialog, to validate input, to expose the algorithm in the model builder, and to accept a dictionary from Python. That single declaration is why the same algorithm works identically from a toolbox click, a model, qgis_process, and your script. It is also why the parameter names are stable enough to script against: they are part of the algorithm's public definition, not an implementation detail.
The consequence for automation is that processing.run is a genuinely thin surface. There is no separate scripting API to learn and no divergence between what the GUI does and what your script does β the History panel proves it, because what it records is literally the call the dialog made. When a colleague asks why the script gives different numbers from their manual run, the answer is almost always a parameter difference, and the two dictionaries can be diffed. The one place the two do differ is settings: the desktop's Processing options, such as invalid-feature filtering, are not the headless defaults, which is why the clip above failed without a context and succeeded with one.
Chaining through TEMPORARY_OUTPUT keeps intermediates as layers in memory instead of files written and re-read between steps. The gdal: algorithms are different in kind: they build a command line and run GDAL, so they always write real files, even for a temporary output.
Finally, the error model. processing.run raises QgsProcessingException on a hard failure, but a great deal of what goes wrong is reported rather than raised β features skipped for invalid geometry above all. Those go to the feedback object, and if you did not pass one they go nowhere you will look. Passing a logging feedback object is the difference between "the job ran" and "the job ran and skipped 146 parcels", which is exactly the kind of thing a batch job's error report exists to surface.
Empty registries, legacy ids and headless selections
Algorithm not found in a standalone script
The registry is empty until Processing is initialised. In a standalone script that means appending the plugins directory to sys.path and calling Processing.initialize() before the first processing.run. If native: algorithms resolve but grass: ones do not, that provider is genuinely not installed rather than not initialised. The setup block on this page is a complete working example.
Legacy qgis: ids still work but are not always what they say
Most qgis: ids of algorithms that were ported to C++ now resolve to the native: algorithm β qgis:checkvalidity runs native:checkvalidity. Thirty-one algorithms remain genuinely in the Python qgis: provider in QGIS 4.2. If you inherited a script full of qgis: ids, resolve each with registry.algorithmById(old).id() and log the id that actually ran.
None is meaningful for optional parameters
Omitting an optional key and passing None are both accepted, but for parameters like SOURCE_CRS passing None explicitly means "detect it", which is what you usually want. Passing an empty string is not the same and often fails validation.
Selections do not exist headlessly
QgsProcessingFeatureSourceDefinition(layer.id(), True) restricts an algorithm to selected features, which is useful in the desktop and meaningless in a scheduled job. Express the subset as an expression instead, so the same script produces the same result whoever runs it.
Output file overwriting
Writing to an existing GeoPackage path replaces the whole file, not one layer β the example above lost 1,131 houses that way. Write a new table with the ogr:dbname='β¦' table="β¦" form, and if a run must never clobber, check Path(out).exists() first; the framework will not stop you.
Threads and progress
processing.run blocks. To keep a GUI responsive you would use QgsProcessingAlgRunnerTask, but for automation blocking is what you want. Do not try to run several algorithms on threads in one process; run separate processes instead, as covered in parallel batch processing.
Internal links
- How to Automate QGIS with Python (PyQGIS): The Complete Workflow
- How to Run a PyQGIS Script Headless Without Opening QGIS
- How to Batch Process Layers in QGIS with PyQGIS
- How to Build a QGIS Processing Model and Run It from Python
- How to Write a Custom QGIS Processing Script Algorithm in Python
- How to Chain GIS Processing Steps into a Reusable Pipeline
- How to Drive a GIS Pipeline from a YAML Config File
- How to Log and Summarise Errors in a Batch GIS Job
FAQ
How do I find the parameter names for an algorithm?
Three reliable ways: run it once from the toolbox and read the recorded call in Processing β History; call processing.algorithmHelp("native:buffer"), which prints every parameter with its type and enum options; or run qgis_process help native:buffer from a terminal. Never copy parameter names from a tutorial without checking: a misspelt key is not an error, it is ignored, and the algorithm runs with that parameter's default.
What is the difference between native: and qgis: algorithms?
native: algorithms are implemented in C++ and are the current, maintained implementations. qgis: is the older Python provider; many of its ids are now aliases forwarding to the native version, but some remain distinct and slower. For new automation, use native: wherever the algorithm exists there.
Why does processing.run say the algorithm is not found?
The Processing registry has not been initialised. Inside QGIS this is done for you; in a standalone script you must append the QGIS plugins directory to sys.path, import Processing from processing.core.Processing, and call Processing.initialize() before the first run. If only third-party providers are missing, install and enable them instead.
Should I use TEMPORARY_OUTPUT or write files between steps?
Use TEMPORARY_OUTPUT for every intermediate. It avoids disk I/O, keeps the workspace clean, and is faster on any non-trivial dataset. Write a real file only where you want a result someone will open, and at the end of the chain β plus, when debugging, at the one step whose output you need to inspect.
How do I see warnings from an algorithm in a scheduled job?
Pass a QgsProcessingFeedback subclass that forwards pushInfo, pushWarning, and reportError into the logging module. Without it, those messages go to the QGIS log panel, which does not exist in a headless run β so a job that skipped hundreds of invalid features looks identical to a clean one.
Can I run Processing algorithms without writing Python at all?
Yes β qgis_process run native:buffer -- INPUT=in.gpkg DISTANCE=25 OUTPUT=out.gpkg runs any algorithm from the shell, with --json for machine-readable output. It is ideal for a single algorithm in a Makefile or CI step. Once you need conditionals, loops, or error handling, move to Python.
Do Processing algorithms handle CRS transformation for me?
Only where the algorithm says so. Overlay algorithms like native:clip will reproject the overlay to the input's CRS, but distance and area parameters are always interpreted in the input layer's units β so a 25-unit buffer on a layer in EPSG:4326 means 25 degrees. Reproject to a metric CRS first; the same trap is covered for the pure-Python stack in calculating area and distance correctly.
How do I pass a selection or a filtered subset to an algorithm?
In the desktop you can wrap the layer in QgsProcessingFeatureSourceDefinition with "selected features only". For automation, filter explicitly with native:extractbyexpression and feed its output into the next step β it is reproducible, it works headlessly, and the expression is visible in the script rather than in someone's session.