Most GIS analysts who try to learn scripting open a blank Python console, type from qgis.core import *, and close the console within ten minutes. PyQGIS documentation dumps you into an object model of two hundred classes with no hint of which ones matter for the three workflows you actually run every week. It is the wrong entry point.
The Model Designer is the right entry point. You already use it to wire up Clip, Reproject, and Extract by Location into a reusable graph. The menu option that nobody talks about is one click away: Export → Export as Python Algorithm. QGIS writes a complete QgsProcessingAlgorithm subclass that reproduces your model exactly, including every processing.run() call with the correct parameter dictionary. You now have a working script to modify instead of a blank console to fight.
This post walks through the transition for a hydrology workflow — the same one from the r/gis discussion where someone wanted to script r.watershed and r.stream.extract but got buried in tutorials about QgsVectorLayer that had nothing to do with their actual problem.
The canvas-first workflow
Before writing any Python, build the graph. For a watershed delineation pipeline the nodes are:
- Fill sinks —
grass:r.fill.dirto remove pit artefacts from the DEM. - Flow accumulation —
grass:r.watershedto produce accumulation, drainage direction, and basin rasters in one call. - Stream extraction —
grass:r.stream.extracton the accumulation raster using a threshold. - Vectorise —
grass:v.out.ogror the nativePolygonizeto convert the stream raster to a line layer. - Clip to AOI —
native:clipagainst a study area polygon.
Wire them up in the Model Designer. Run the model once against a real DEM to confirm the thresholds produce what you want. Save it. Now right-click the model in the Processing Toolbox and pick Export Model as Python Algorithm. QGIS writes a file like this:
"""
Model exported as python.
Name : Watershed Delineation
Group : hydrology
With QGIS : 33400
"""
from qgis.core import QgsProcessing
from qgis.core import QgsProcessingAlgorithm
from qgis.core import QgsProcessingMultiStepFeedback
from qgis.core import QgsProcessingParameterRasterLayer
from qgis.core import QgsProcessingParameterVectorLayer
from qgis.core import QgsProcessingParameterNumber
from qgis.core import QgsProcessingParameterRasterDestination
from qgis.core import QgsProcessingParameterVectorDestination
import processing
class WatershedDelineation(QgsProcessingAlgorithm):
def initAlgorithm(self, config=None):
self.addParameter(QgsProcessingParameterRasterLayer('dem', 'DEM', defaultValue=None))
self.addParameter(QgsProcessingParameterVectorLayer('aoi', 'Area of interest', types=[QgsProcessing.TypeVectorPolygon], defaultValue=None))
self.addParameter(QgsProcessingParameterNumber('threshold', 'Stream threshold (cells)', type=QgsProcessingParameterNumber.Integer, minValue=1, defaultValue=500))
self.addParameter(QgsProcessingParameterRasterDestination('Accumulation', 'Accumulation', createByDefault=True, defaultValue=None))
self.addParameter(QgsProcessingParameterVectorDestination('Streams', 'Streams', type=QgsProcessing.TypeVectorLine, createByDefault=True, defaultValue=None))
def processAlgorithm(self, parameters, context, model_feedback):
feedback = QgsProcessingMultiStepFeedback(4, model_feedback)
results = {}
outputs = {}
# r.fill.dir
alg_params = {
'input': parameters['dem'],
'format': 0, # grass
'areas': QgsProcessing.TEMPORARY_OUTPUT,
'direction': QgsProcessing.TEMPORARY_OUTPUT,
'output': QgsProcessing.TEMPORARY_OUTPUT
}
outputs['Rfilldir'] = processing.run('grass:r.fill.dir', alg_params, context=context, feedback=feedback, is_child_algorithm=True)
feedback.setCurrentStep(1)
if feedback.isCanceled():
return {}
# r.watershed
alg_params = {
'elevation': outputs['Rfilldir']['output'],
'threshold': parameters['threshold'],
'accumulation': parameters['Accumulation'],
'basin': QgsProcessing.TEMPORARY_OUTPUT,
'drainage': QgsProcessing.TEMPORARY_OUTPUT,
'stream': QgsProcessing.TEMPORARY_OUTPUT,
'-4': False, '-a': False, '-b': False, '-m': False, '-s': False
}
outputs['Rwatershed'] = processing.run('grass:r.watershed', alg_params, context=context, feedback=feedback, is_child_algorithm=True)
results['Accumulation'] = outputs['Rwatershed']['accumulation']
feedback.setCurrentStep(2)
if feedback.isCanceled():
return {}
# r.stream.extract
alg_params = {
'elevation': outputs['Rfilldir']['output'],
'accumulation': outputs['Rwatershed']['accumulation'],
'threshold': parameters['threshold'],
'stream_vector': QgsProcessing.TEMPORARY_OUTPUT,
'stream_raster': QgsProcessing.TEMPORARY_OUTPUT
}
outputs['StreamExtract'] = processing.run('grass:r.stream.extract', alg_params, context=context, feedback=feedback, is_child_algorithm=True)
feedback.setCurrentStep(3)
if feedback.isCanceled():
return {}
# Clip
alg_params = {
'INPUT': outputs['StreamExtract']['stream_vector'],
'OVERLAY': parameters['aoi'],
'OUTPUT': parameters['Streams']
}
outputs['Clip'] = processing.run('native:clip', alg_params, context=context, feedback=feedback, is_child_algorithm=True)
results['Streams'] = outputs['Clip']['OUTPUT']
return results
def name(self):
return 'Watershed Delineation'
def displayName(self):
return 'Watershed Delineation'
def group(self):
return 'hydrology'
def groupId(self):
return 'hydrology'
def createInstance(self):
return WatershedDelineation()
This is not toy code. This is the exact algorithm you built in the canvas, written in Python, with every algorithm ID already correct. You did not have to learn that GRASS’s r.watershed uses elevation as its input key or that native:clip expects INPUT and OVERLAY in uppercase — QGIS worked all of that out for you.
Reading the exported code
Three things are worth internalising before you modify anything.
Algorithm IDs follow a provider:name pattern. native:clip runs the C++-backed QGIS native clip. qgis: runs the older Python-based versions of algorithms that have not been ported. grass: runs GRASS modules through QGIS’s GRASS provider. gdal: runs GDAL command-line tools. whitebox: runs Whitebox Tools if you installed it. When you read a script you can tell, at a glance, how heavy each step is.
Parameter dictionaries are per-algorithm contracts. The key names (elevation, threshold, stream_vector) are exactly what each algorithm expects — they are NOT PyQGIS conventions. To discover the dictionary for any algorithm without running the modeler, run this in the Python console:
import processing
processing.algorithmHelp('grass:r.watershed')
That prints the full parameter list with types and defaults. It is the single most useful command in PyQGIS and nobody tells beginners about it.
is_child_algorithm=True is the invisible switch. When the modeler chains algorithms it sets this flag so output layers do not get loaded into the project between steps. If you call processing.run() from the Python console interactively, leave it off — you probably want to see the intermediate results.
Dropping into the console for iteration
The next step in the on-ramp is to stop thinking about the exported script as “a script” and start using it as a cheat sheet. Open the Python console (Plugins → Python Console), and paste the single processing.run() call you want to modify:
import processing
result = processing.run('grass:r.watershed', {
'elevation': '/data/srtm_clipped.tif',
'threshold': 1000,
'accumulation': '/tmp/accum.tif',
'stream': '/tmp/streams.tif',
'basin': 'TEMPORARY_OUTPUT',
'drainage': 'TEMPORARY_OUTPUT',
'-s': True # Single-flow direction (D8)
})
print(result)
Run it. You get back a dict with each declared output path. Load one into the canvas with iface.addRasterLayer(result['accumulation'], 'accumulation') and inspect it. Change the threshold, re-run, inspect again. That loop — change one parameter, re-run, see the result — is how you actually learn the tools. The modeler is too heavyweight for this stage; the console is exactly right.
When you are ready, migrate from processing.run() (returns a dict of paths) to processing.runAndLoadResults() (returns the same dict but also adds the outputs to the current project). The difference matters during exploration: runAndLoadResults saves you three lines of boilerplate per run.
Hydrology-specific gotchas
The GRASS provider in QGIS is a thin wrapper around the actual GRASS modules, and a few of its behaviours bite people on the way up the learning curve.
GRASS region vs QGIS extent. GRASS modules respect a concept called the “computational region” that is separate from the extent of your raster. The provider sets this automatically for you based on the input extent, but if you ever call GRASS directly from the command line you will get wildly wrong results if you skip g.region. You do not need to care inside QGIS, but you do need to know why a GRASS tutorial from outside QGIS looks different.
r.watershed memory setting. The memory parameter (default 300 MB) is a hard cap on how much RAM the algorithm will use. On a DEM with 100M+ cells the default forces disk paging and slows the run by 10–20x. Set it to 4000 or 8000 on a workstation with ample RAM.
Flag parameters are their own dictionary keys. GRASS flags like -s (single flow direction) show up in the exported Python as '-s': True. They are not positional. The exported script preserves them for you — do not “clean up” what looks like a typo.
Whitebox and GRASS disagree on pit-filling. grass:r.fill.dir uses a constrained algorithm that preserves flow paths where possible. whitebox:FillDepressionsWangAndLiu is a different algorithm entirely and produces subtly different accumulation grids. If your streams move when you switch providers, this is usually why. The GIS team discussion in the r/gis thread that prompted this post hit exactly this issue.
When to graduate to a custom processing algorithm
Running chains of processing.run() in the console is good for experimentation but breaks down as a permanent workflow. Three signs you should turn it back into a model (or a full custom algorithm):
- You have five or more
processing.run()calls that depend on each other. The console is no longer the right place; use the exported class or the modeler. - You need to share the tool with a colleague who does not read Python. Put the class under
~/.local/share/QGIS/QGIS3/profiles/default/processing/scripts/and it shows up in their Processing Toolbox with a real dialog. - You are calling the same pipeline with varying inputs. Parameterise it via
QgsProcessingParameterRasterLayerandQgsProcessingParameterNumberdeclarations ininitAlgorithm()and you get a UI for free.
Option 3 is where the Model Designer → Python → Custom Algorithm arc pays off. The exported class already has proper initAlgorithm() declarations. You add a couple of QgsProcessingParameterFile entries, wrap the parameters dict with self.parameterAsInt(parameters, 'threshold', context), and you have a first-class QGIS processing algorithm that your colleagues run from the toolbox without ever seeing Python.
Where the conversion pipeline intersects
Much of this on-ramp is about making GRASS and SAGA more scriptable. When your workflow outputs are spatial vector data that then needs to leave QGIS — to a client’s ArcGIS project, a CAD file for a civil engineer, or a columnar format for a Python analysis — you run into the same conversion problems everyone does. A watershed vector layer that has clean Polygon geometry in QGIS will lose attributes silently in Shapefile (10-character column limit), reproject incorrectly if the destination CRS is not set, and can fragment across vertex limits. Understanding why GeoJSON is an interchange format, not a working one and the quiet corruptions that slip through GeoJSON-to-Shapefile conversion matters as much as the hydrology itself once you hand off.
The same preflight checks that catch problems in external data — field truncation, CRS mismatch, null geometry — apply to your own outputs. Run them in Python at the end of your pipeline with ogr2ogr -f "ESRI Shapefile" and a layer-info check before you email the file to a client. For format conversion you do not want to scripts-and-libraries yourself, CadShift GeoConvert takes a GeoJSON from your pipeline and produces a Shapefile with GDAL-verified geometry and CRS in one upload — useful when the person receiving the file has opinions about projections you do not want to relitigate.
The short version of the learning path
- Do not start in the Python console with an empty buffer. You will learn nothing useful for four hours.
- Build your workflow in the Model Designer first. Get the algorithm IDs and parameter names right visually.
- Export as Python Algorithm. Read the generated code. Every
processing.run()call is a template. - Paste individual
processing.run()calls into the console and iterate on parameters. This is where the real learning happens. - Use
processing.algorithmHelp('provider:name')to discover parameters without the documentation site. - Graduate back to a custom algorithm once the pipeline is stable and needs a UI.
Hydrology-specific toolchains — grass:r.watershed, grass:r.stream.extract, Whitebox’s D8Pointer and D8FlowAccumulation — are among the most parameter-heavy in GIS. They are also among the best documented once you know the algorithm help command. The Model Designer is the bridge that turns the whole thing from a reference-manual slog into something you can iterate on in half-hour sessions.