Expressions, Scripts, Models or Plugins? QGIS Extension Points Explained
Problem statement
You have a repeatable task β clip every layer to a boundary, recalculate a field, export a set of maps β and QGIS offers at least five ways to automate it:
an expression in the Field Calculator
a snippet in the Python Console
a Processing script algorithm
a model built in the Graphical Modeler
a standalone PyQGIS script run with python3
a plugin installed into QGIS
They are not interchangeable. An expression cannot open a file; a Console snippet cannot be scheduled; a model cannot express a loop with a condition; a plugin cannot run headless on a server. Choosing wrongly means either fighting the tool or building far more than the job needed.
The good news is that the choice follows from four questions, and the extension points form a clear ladder.
Quick answer
Match the extension point to who runs it and where:
- Expression β one computed value per feature, inside a dialog. No files, no control flow.
- Console snippet β exploration and one-off jobs, by you, now.
- Processing script algorithm β a reusable, parameterised operation with a dialog, usable in models, batch mode and
qgis_process. - Model β a visual chain of existing algorithms, built by a non-programmer, runnable headless.
- Standalone PyQGIS script β scheduled, unattended automation with full control flow.
- Plugin β new GUI: panels, toolbar buttons, map tools, for other people inside QGIS.
# expression β per feature, no imports
round($area / 10000, 2)
# console snippet β right now, in this project
for layer in QgsProject.instance().mapLayers().values():
print(layer.name(), layer.featureCount())
# script algorithm β parameterised, appears in the Toolbox
class ClipToBoundary(QgsProcessingAlgorithm): ...
# standalone script β scheduled, headless
python3 -m src.pipeline --config configs/daily.yml
The rule of thumb: stay as high on that list as the job allows. Each step down costs setup, testing and maintenance, and buys capability you may not need.
The ladder
Step-by-step solution
Expressions: one value per feature
round($area / 10000, 2)
concat("street", ' ', upper("town"))
CASE WHEN "pop" > 5000 THEN 'large' WHEN "pop" > 500 THEN 'medium' ELSE 'small' END
aggregate('zones', 'max', "score", intersects($geometry, geometry(@parent)))
Expressions run everywhere in QGIS: the Field Calculator, layer filters, labels, data-defined symbology, atlas coverage, model parameters. They are fast, they are the same language throughout, and they need no environment.
Their limits are absolute: no file access, no loops, no state between features. When you find yourself writing a 40-line nested CASE, that is the signal to move up a rung.
# reusable expression functions, registered from Python
from qgis.core import qgsfunction
@qgsfunction(args="auto", group="Custom", referenced_columns=[])
def hectares(area_m2, feature, parent):
"""Convert square metres to hectares. hectares("area_m2")"""
return round(area_m2 / 10_000, 3)
A @qgsfunction in the project's expressions/ folder or a startup script gives everyone in the project a shared vocabulary β the lightest possible extension.
Console snippets: for now, by you
# QGIS Python Console β everything is already initialised
from qgis.core import QgsProject
import processing
for layer in QgsProject.instance().mapLayers().values():
if layer.type() != layer.VectorLayer:
continue
result = processing.run("native:buffer", {
"INPUT": layer, "DISTANCE": 25, "OUTPUT": "TEMPORARY_OUTPUT"})
QgsProject.instance().addMapLayer(result["OUTPUT"])
The Console is the fastest way to explore an API, check a value, or do something once across the open project. Because QGIS is already running, there is no initialisation, no teardown and no environment to configure.
What it is not: repeatable by anyone else, parameterised, or schedulable. A snippet you find yourself pasting weekly is asking to become a script algorithm.
Processing script algorithms: reusable and parameterised
from qgis.core import (QgsProcessingAlgorithm, QgsProcessingParameterFeatureSource,
QgsProcessingParameterFeatureSink, QgsProcessingParameterNumber,
QgsProcessing, QgsFeatureSink, QgsProcessingException)
class ClipAndBuffer(QgsProcessingAlgorithm):
INPUT = "INPUT"
BOUNDARY = "BOUNDARY"
DISTANCE = "DISTANCE"
OUTPUT = "OUTPUT"
def initAlgorithm(self, config=None):
self.addParameter(QgsProcessingParameterFeatureSource(
self.INPUT, "Input layer", [QgsProcessing.TypeVectorAnyGeometry]))
self.addParameter(QgsProcessingParameterFeatureSource(
self.BOUNDARY, "Boundary", [QgsProcessing.TypeVectorPolygon]))
self.addParameter(QgsProcessingParameterNumber(
self.DISTANCE, "Buffer distance", defaultValue=25,
type=QgsProcessingParameterNumber.Double, minValue=0))
self.addParameter(QgsProcessingParameterFeatureSink(self.OUTPUT, "Result"))
def processAlgorithm(self, parameters, context, feedback):
source = self.parameterAsSource(parameters, self.INPUT, context)
if source is None:
raise QgsProcessingException("invalid input layer")
distance = self.parameterAsDouble(parameters, self.DISTANCE, context)
sink, dest_id = self.parameterAsSink(
parameters, self.OUTPUT, context, source.fields(),
source.wkbType(), source.sourceCrs())
total = source.featureCount() or 1
for i, feature in enumerate(source.getFeatures()):
if feedback.isCanceled():
break
feature.setGeometry(feature.geometry().buffer(distance, 8))
sink.addFeature(feature, QgsFeatureSink.FastInsert)
feedback.setProgress(int(i / total * 100))
return {self.OUTPUT: dest_id}
def name(self): return "clipandbuffer"
def displayName(self): return "Clip and buffer"
def group(self): return "Custom"
def groupId(self): return "custom"
def createInstance(self): return ClipAndBuffer()
For declaring parameters and writing one function, you get: a Toolbox entry with a generated dialog, batch mode, a place in the Modeler, a qgis_process command, progress reporting and cancellation. This is the highest-value rung on the ladder and the one most often skipped.
Models: chains built visually
A model built in the Graphical Modeler is a .model3 file β a directed graph of existing algorithms with named inputs. It is the right tool when the workflow is a chain of standard operations and the person maintaining it is not a programmer.
qgis_process list | grep '^model:'
qgis_process help model:parcel_cleanup
# run a model file directly β no profile needed, ideal for CI
qgis_process run models/parcel_cleanup.model3 -- \
INPUT=data/raw/parcels.gpkg OUTPUT=data/out/clean.gpkg
import processing
processing.run("model:parcel_cleanup",
{"INPUT": "data/raw/parcels.gpkg", "OUTPUT": "data/out/clean.gpkg"})
Models are genuinely underrated: they are inspectable by non-programmers, they run headless, and they register as algorithms like any other. Their limit is control flow β conditionals and loops are awkward or impossible, and a model that has grown branches wants to be a script.
Standalone scripts: scheduled and unattended
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import QgsApplication, QgsProject
import sys
QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False)
qgs.initQgis()
sys.path.append("/usr/share/qgis/python/plugins")
from processing.core.Processing import Processing
Processing.initialize()
try:
import processing
processing.run("native:buffer", {
"INPUT": "data/raw/parcels.gpkg", "DISTANCE": 25,
"OUTPUT": "data/out/buffered.gpkg"})
finally:
QgsProject.instance().clear()
qgs.exitQgis()
This is the rung where the QGIS lifecycle becomes yours to manage β initialise, run, tear down β in exchange for full control flow, scheduling, and integration with the rest of a pipeline. For a single algorithm, qgis_process is simpler; write a standalone script when there is real logic between the steps.
Plugins: new interface for other people
# minimal plugin shape
from qgis.PyQt.QtWidgets import QAction
from qgis.PyQt.QtGui import QIcon
class ParcelToolsPlugin:
def __init__(self, iface):
self.iface = iface # the QGIS interface β GUI only
self.action = None
def initGui(self):
self.action = QAction(QIcon(":/plugins/parcel_tools/icon.png"),
"Clean parcels", self.iface.mainWindow())
self.action.triggered.connect(self.run)
self.iface.addToolBarIcon(self.action)
self.iface.addPluginToMenu("&Parcel tools", self.action)
def unload(self):
self.iface.removeToolBarIcon(self.action)
self.iface.removePluginMenu("&Parcel tools", self.action)
def run(self):
layer = self.iface.activeLayer()
if layer is None:
self.iface.messageBar().pushWarning("Parcel tools", "Select a layer first")
return
...
A plugin is the answer when other people need a button: a dockable panel, a map tool that responds to clicks, a wizard, a custom form. It requires metadata.txt, an installable package, and a maintenance commitment across QGIS versions.
The pattern worth knowing: a plugin that also registers a Processing provider gets both worlds β a GUI for interactive users and algorithms that work in models, batch mode and qgis_process.
from qgis.core import QgsApplication, QgsProcessingProvider
class ParcelProvider(QgsProcessingProvider):
def loadAlgorithms(self):
self.addAlgorithm(ClipAndBuffer())
def id(self): return "parceltools"
def name(self): return "Parcel tools"
# in initGui:
QgsApplication.processingRegistry().addProvider(ParcelProvider())
Code examples
Example 1: the same task at four rungs
# 1. expression β per feature, in the Field Calculator
# round($area / 10000, 2)
# 2. console snippet β once, on the active layer
layer = iface.activeLayer()
layer.startEditing()
idx = layer.fields().indexOf("area_ha")
for f in layer.getFeatures():
layer.changeAttributeValue(f.id(), idx, round(f.geometry().area() / 10_000, 2))
layer.commitChanges()
# 3. processing algorithm β reusable, in the Toolbox and the CLI
import processing
processing.run("native:fieldcalculator", {
"INPUT": layer, "FIELD_NAME": "area_ha", "FIELD_TYPE": 0,
"FORMULA": "round($area / 10000, 2)", "OUTPUT": "data/out/parcels.gpkg"})
# 4. standalone script β scheduled over a folder
for path in sorted(Path("data/raw").glob("*.gpkg")):
processing.run("native:fieldcalculator", {
"INPUT": str(path), "FIELD_NAME": "area_ha", "FIELD_TYPE": 0,
"FORMULA": "round($area / 10000, 2)",
"OUTPUT": f"data/out/{path.name}"})
The same computation, four times, each usable in a different context. Recognising which context you are actually in is the whole decision.
Example 2: promote a snippet into an algorithm
# before: a Console snippet nobody else can run
BOUNDARY = "/home/anna/data/city.gpkg"
DISTANCE = 25
layer = iface.activeLayer()
...
# after: the same logic, parameterised
class ClipToBoundary(QgsProcessingAlgorithm):
def initAlgorithm(self, config=None):
self.addParameter(QgsProcessingParameterFeatureSource("INPUT", "Layer"))
self.addParameter(QgsProcessingParameterFeatureSource("BOUNDARY", "Boundary"))
self.addParameter(QgsProcessingParameterNumber("DISTANCE", "Distance", defaultValue=25))
self.addParameter(QgsProcessingParameterFeatureSink("OUTPUT", "Clipped"))
...
The promotion is mostly mechanical: every hard-coded value becomes a parameter, and the parameter declaration generates the dialog. That is what makes this rung such good value.
Example 3: run every extension point from one place
import processing
from qgis.core import QgsApplication
def run_anything(kind: str, ident: str, params: dict):
"""Algorithms, models and script algorithms all resolve the same way."""
registry = QgsApplication.processingRegistry()
alg = registry.algorithmById(ident)
if alg is None:
raise ValueError(f"{ident} is not registered "
f"(providers: {[p.id() for p in registry.providers()]})")
return processing.run(ident, params)
run_anything("builtin", "native:buffer", {...})
run_anything("model", "model:parcel_cleanup", {...})
run_anything("script", "script:clipandbuffer", {...})
Because models and script algorithms register alongside the built-ins, calling code does not need to know which is which β the reason to prefer them over ad-hoc snippets.
Example 4: where each one lives on disk
from pathlib import Path
from qgis.core import QgsApplication
profile = Path(QgsApplication.qgisSettingsDirPath())
print("QGIS profile:", profile)
for name, sub in {
"processing scripts": "processing/scripts",
"models": "processing/models",
"plugins": "python/plugins",
"expression functions": "python/expressions",
"styles": "symbology-style.db",
}.items():
path = profile / sub
print(f"{name:22} {path} {'β' if path.exists() else 'β'}")
Knowing the profile layout matters for deployment: a model or script algorithm that works on your machine and not on the server is usually one that lives in your profile and was never copied.
Explanation
QGIS is unusually extensible because it exposes hooks at several levels of the same architecture, and each hook was designed for a different person.
Expressions sit closest to the data. They are evaluated per feature inside an existing operation, which is why they are so fast and so limited: no I/O, no state, no control flow. Their strength is ubiquity β the same expression works in the Field Calculator, in a filter, in labels and in data-defined symbology β and a custom @qgsfunction extends that vocabulary for a whole project at almost no cost.
The Processing framework is the middle of the architecture and the sweet spot for automation. Because an algorithm declares its parameters, one implementation yields a dialog, batch mode, a Modeler node and a CLI command. That is a remarkable return for writing initAlgorithm and processAlgorithm, and it is why promoting a repeated snippet to a script algorithm is usually the highest-value refactor available.
Models are the same framework approached from the other end: instead of writing an algorithm, you compose existing ones visually. The result is a first-class algorithm that a non-programmer can maintain and that runs headless from qgis_process. The boundary is control flow β the Modeler has no real conditionals or loops, so a model with branches is a script waiting to be written.
Standalone scripts and plugins sit at opposite ends of the top of the ladder, distinguished by where they run. A standalone script owns the QGIS lifecycle and runs without a GUI, which is what makes scheduling possible. A plugin runs inside a live QGIS with iface available, which is what makes new interface elements possible β and simultaneously means it cannot run on a server.
That last distinction is the one to hold onto, because it is the most common mistake: building a plugin for a job that must run nightly on a server. If nobody clicks anything, it is not a plugin β it is a script algorithm or a standalone script, and the plugin's GUI is pure overhead.
Edge cases or notes
- Plugins need
iface; scripts must not use it: Anything referencingifacecannot run headless. - Script algorithms live in the profile:
processing/scriptsin the active profile β copy them when deploying to a server. - Models are
.model3files: They can be run by path, which avoids profile issues in CI. - Expressions cannot open files: By design. A custom
@qgsfunctioncan, but doing I/O per feature is a performance trap. - A plugin can register a Processing provider: The best of both β GUI for interactive users, algorithms for automation.
- The Console has
iface, standalone scripts do not: Snippets copied out of the Console often break for this reason alone. - Version compatibility is a plugin cost: The QGIS API changes across releases, and a plugin must be maintained; a Processing algorithm has a much smaller surface.
Internal links
- How to Write a Custom QGIS Processing Script Algorithm in Python
- How to Build a QGIS Processing Model and Run It from Python
- The QGIS Processing Framework Explained
- How to Run Field Calculator Expressions from PyQGIS
- How to Automate QGIS from the Command Line with qgis_process
- How to Run a PyQGIS Script Headless Without Opening QGIS
FAQ
When should I write a plugin?
Only when other people need new interface elements inside QGIS β a panel, a toolbar button, a map tool. If the job runs unattended, a Processing algorithm or a standalone script is the right answer.
What is the difference between a script algorithm and a standalone script?
A script algorithm runs inside QGIS's Processing framework and gets a dialog, batch mode and CLI access for free. A standalone script initialises QGIS itself and can be scheduled.
When is a model better than code?
When the workflow is a chain of existing algorithms and the person maintaining it is not a programmer. Models are visual, inspectable and runnable headless β until you need conditionals or loops.
Can expressions do everything the Field Calculator dialog can?
Yes β the dialog is a front end to the same expression engine, and the same language is used in filters, labels and data-defined symbology.
How do I share a script algorithm with my team?
Put it in processing/scripts in the profile, or ship it inside a plugin that registers a Processing provider. The second travels better and is versionable.
Can a plugin run on a server?
No. Plugins depend on iface and a running GUI. Move the logic into a Processing algorithm, which works in both worlds.
What is the cheapest thing that could work?
Usually an expression or a Processing algorithm that already exists. Check qgis_process list before writing anything β a surprising number of tasks are already a built-in algorithm away.