Expressions, Scripts, Models or Plugins? QGIS Extension Points Explained
On this page
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's interface cannot run 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. Everything on this page except the plugin's buttons was run under QGIS 4.2, headless, on Rutland's schools, wards and Land Registry parcels.
Quick answer
Match the extension point to who runs it and where:
- Expression — one computed value per feature, inside a dialog or a
QgsExpression. 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 BufferWithinBoundary(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
From expressions to plugins, rung by rung
Expressions: one value per feature
examples = [
"round($area / 10000, 2)",
"concat(\"name\", ' (', upper(\"fclass\"), ')')",
"CASE WHEN $area > 50000 THEN 'large' WHEN $area > 10000 THEN 'medium' ELSE 'small' END",
"aggregate('wards', 'concatenate', \"WD24NM\", intersects($geometry, geometry(@parent)), ' + ')",
]
schools = QgsVectorLayer("data/schools.gpkg|layername=schools", "schools", "ogr")
QgsProject.instance().addMapLayers([schools, QgsVectorLayer("data/wards.geojson", "wards", "ogr")])
oakham = next(schools.getFeatures("\"name\" = 'Oakham School'"))
context = QgsExpressionContext(QgsExpressionContextUtils.globalProjectLayerScopes(schools))
context.setFeature(oakham)
for text in examples:
expression = QgsExpression(text)
expression.prepare(context)
print(f"{expression.evaluate(context)!r:34} <- {text[:58]}")
28.58 <- round($area / 10000, 2)
'Oakham School (SCHOOL)' <- concat("name", ' (', upper("fclass"), ')')
'large' <- CASE WHEN $area > 50000 THEN 'large' WHEN $area > 10000 TH
'Oakham North East + Oakham South' <- aggregate('wards', 'concatenate', "WD24NM", intersects($ge
Expressions run everywhere in QGIS: the Field Calculator, layer filters, labels, data-defined symbology, atlas coverage, model parameters — and, through QgsExpression, in a headless script, as above. They are fast, they are the same language throughout, and they need no environment. The aggregate() call reached into another layer in the project to find the wards Oakham School's grounds fall in — two of them, which a point-based lookup would have missed — and that is about as far as an expression can go.
Their limits are real: 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(group="Custom", referenced_columns=[])
def hectares(area_m2, feature, parent):
"""Convert square metres to hectares. hectares($area)"""
return round(area_m2 / 10_000, 3)
expression = QgsExpression("hectares($area)")
expression.prepare(context)
print(f"Oakham School: {expression.evaluate(context)} ha", "| parser error:", expression.hasParserError())
Oakham School: 28.58 ha | parser error: False
A @qgsfunction saved in the profile's python/expressions/ folder is loaded when QGIS Desktop starts, and a project can also embed functions of its own — the lightest possible extension, and a shared vocabulary for everyone who uses the project.
Console snippets: for now, by you
# In the QGIS Python Console everything is already initialised; here the project holds schools and wards
project = QgsProject.instance()
for layer in project.mapLayers().values():
if layer.type() != Qgis.LayerType.Vector:
continue
result = processing.run("native:buffer", {
"INPUT": layer, "DISTANCE": 25, "OUTPUT": "TEMPORARY_OUTPUT"})
print(f"{layer.name():8} -> {result['OUTPUT'].featureCount()} buffered features")
schools -> 26 buffered features
wards -> 15 buffered features
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
ALGORITHM = '''
from qgis.core import (QgsFeatureSink, QgsGeometry, QgsProcessing, QgsProcessingAlgorithm,
QgsProcessingException, QgsProcessingParameterFeatureSink,
QgsProcessingParameterFeatureSource, QgsProcessingParameterNumber)
class BufferWithinBoundary(QgsProcessingAlgorithm):
INPUT, BOUNDARY, DISTANCE, OUTPUT = "INPUT", "BOUNDARY", "DISTANCE", "OUTPUT"
def initAlgorithm(self, config=None):
self.addParameter(QgsProcessingParameterFeatureSource(
self.INPUT, "Input layer", [QgsProcessing.SourceType.TypeVectorAnyGeometry]))
self.addParameter(QgsProcessingParameterFeatureSource(
self.BOUNDARY, "Boundary", [QgsProcessing.SourceType.TypeVectorPolygon]))
self.addParameter(QgsProcessingParameterNumber(
self.DISTANCE, "Buffer distance", defaultValue=25,
type=QgsProcessingParameterNumber.Type.Double, minValue=0))
self.addParameter(QgsProcessingParameterFeatureSink(self.OUTPUT, "Result"))
def processAlgorithm(self, parameters, context, feedback):
source = self.parameterAsSource(parameters, self.INPUT, context)
boundary = self.parameterAsSource(parameters, self.BOUNDARY, context)
if source is None or boundary is None:
raise QgsProcessingException("invalid input or boundary layer")
distance = self.parameterAsDouble(parameters, self.DISTANCE, context)
limit = QgsGeometry.unaryUnion([f.geometry() for f in boundary.getFeatures()])
sink, dest_id = self.parameterAsSink(parameters, self.OUTPUT, context, source.fields(),
limit.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).intersection(limit))
sink.addFeature(feature, QgsFeatureSink.Flag.FastInsert)
feedback.setProgress(int(i / total * 100))
return {self.OUTPUT: dest_id}
def name(self): return "bufferwithinboundary"
def displayName(self): return "Buffer within boundary"
def group(self): return "Custom"
def groupId(self): return "custom"
def createInstance(self): return BufferWithinBoundary()
'''
scripts = profile / "processing/scripts"
scripts.mkdir(parents=True, exist_ok=True)
(scripts / "buffer_within_boundary.py").write_text(ALGORITHM)
registry = QgsApplication.processingRegistry()
registry.providerById("script").refreshAlgorithms() # what the Toolbox's "reload scripts" does
print(registry.algorithmById("script:bufferwithinboundary").displayName())
result = processing.run("script:bufferwithinboundary", {
"INPUT": "data/schools.gpkg|layername=schools", "BOUNDARY": "data/district.geojson",
"DISTANCE": 1000, "OUTPUT": "out/school_zones.gpkg"})
zones = QgsVectorLayer(result["OUTPUT"], "zones", "ogr")
print(zones.featureCount(), "zones,", f"{sum(f.geometry().area() for f in zones.getFeatures()) / 1e6:.1f} km² inside Rutland")
Buffer within boundary
26 zones, 102.2 km² inside Rutland
Saving the class into the profile's processing/scripts folder and refreshing the script provider was all it took for it to register as script:bufferwithinboundary. 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. The setup built a one-step model in code — a 500 m buffer — and saved it twice, to a .model3 file beside the page and to the profile's processing/models folder.
def qgis_process(*args):
run = subprocess.run(["qgis_process", *args], capture_output=True, text=True,
env=dict(os.environ, QT_QPA_PLATFORM="offscreen"))
return [l for l in (run.stdout + run.stderr).splitlines() if l.strip()]
# run a model file directly - no profile needed, ideal for CI
print(next(l for l in qgis_process("run", "schoolbuffer.model3", "--", "INPUT=data/schools.gpkg|layername=schools",
"buf:OUTPUT=out/model_qp.gpkg") if l.startswith("buf:OUTPUT")))
# from Python: by id once it is in the profile, or loaded from the file
print(processing.run("model:schoolbuffer", {"INPUT": "data/schools.gpkg|layername=schools",
"buf:OUTPUT": "out/model_id.gpkg"})["buf:OUTPUT"])
try:
processing.run("schoolbuffer.model3", {})
except Exception as exc:
print(type(exc).__name__ + ":", str(exc).strip())
model = QgsProcessingModelAlgorithm()
model.fromFile("schoolbuffer.model3")
print(processing.run(model, {"INPUT": "data/schools.gpkg|layername=schools",
"buf:OUTPUT": "out/model_file.gpkg"})["buf:OUTPUT"])
buf:OUTPUT: out/model_qp.gpkg
out/model_id.gpkg
QgsProcessingException: Error: Algorithm schoolbuffer.model3 not found
out/model_file.gpkg
Two details from that run. The model's output parameter is not called OUTPUT but buf:OUTPUT — the child algorithm's id and its output name — so read the names from the model rather than guessing (building a model has the rest). And processing.run() does not accept a path to a .model3 file, although qgis_process does; from Python, load the file into a QgsProcessingModelAlgorithm and pass the object.
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
Path("buffer_schools.py").write_text('''
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import QgsApplication, QgsProject
qgs = QgsApplication([], False)
qgs.initQgis()
sys.path.append("/usr/share/qgis/python/plugins")
from processing.core.Processing import Processing
Processing.initialize()
try:
import processing
result = processing.run("native:buffer", {
"INPUT": "data/schools.gpkg|layername=schools", "DISTANCE": 25,
"OUTPUT": "out/buffered.gpkg"})
print("wrote", result["OUTPUT"])
finally:
QgsProject.instance().clear()
qgs.exitQgis()
''')
run = subprocess.run([sys.executable, "buffer_schools.py"], capture_output=True, text=True)
print("exit", run.returncode, "|", run.stdout.strip())
exit 0 | wrote out/buffered.gpkg
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 - needs QGIS Desktop, because of iface
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 GUI half cannot be run headless — it needs iface — and it was not run for this page.
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. The provider needs no iface, so a headless script can register it directly:
from qgis.core import QgsProcessingProvider
namespace = {}
exec(ALGORITHM, namespace) # the same class the scripts folder loaded
class ParcelProvider(QgsProcessingProvider):
def loadAlgorithms(self):
self.addAlgorithm(namespace["BufferWithinBoundary"]())
def id(self): return "parceltools"
def name(self): return "Parcel tools"
QgsApplication.processingRegistry().addProvider(ParcelProvider()) # in a plugin: in initGui
print([a.id() for a in QgsApplication.processingRegistry().algorithms() if "withinboundary" in a.id()])
['parceltools:bufferwithinboundary', 'script:bufferwithinboundary']
That is how QGIS's own GRASS support works: grassprovider is a plugin, and headless scripts get its 307 algorithms because Processing registers the provider without the plugin's GUI.
One Rutland task at four levels
The data used on this page
Rutland from open data: schools from Geofabrik's 1 January 2026 OpenStreetMap extract (© OpenStreetMap contributors, ODbL); the county and its 15 wards from the ONS (December 2024) boundaries; and 300 of HM Land Registry's INSPIRE parcels (© Crown copyright and database rights, HM Land Registry; OGL), split into three files for the folder loop. QGIS runs with a profile inside the working directory (QGIS_CUSTOM_CONFIG_PATH), so the script and model the setup installs do not touch anyone's real profile.
import os
import subprocess
import sys
from pathlib import Path
import requests
from osgeo import gdal
gdal.UseExceptions()
for folder in ("data/raw", "out"):
Path(folder).mkdir(parents=True, exist_ok=True)
session = requests.Session()
if not Path("data/schools.gpkg").exists():
Path("data/rutland.shp.zip").write_bytes(session.get(
"https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
gdal.VectorTranslate("data/schools.gpkg", "/vsizip/data/rutland.shp.zip/gis_osm_pois_a_free_1.shp",
layerName="schools", dstSRS="EPSG:27700", reproject=True, where="fclass = 'school'")
ons = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/{}/FeatureServer/0/query"
Path("data/district.geojson").write_bytes(session.get(
ons.format("Local_Authority_Districts_December_2024_Boundaries_UK_BFC"),
params={"where": "LAD24NM='Rutland'", "outFields": "LAD24NM", "outSR": 27700, "f": "geojson"}, timeout=120).content)
Path("data/wards.geojson").write_bytes(session.get(
ons.format("Wards_December_2024_Boundaries_UK_BFC"),
params={"where": "LAD24CD='E06000017'", "outFields": "WD24NM", "outSR": 27700, "f": "geojson"}, timeout=120).content)
Path("data/Rutland.zip").write_bytes(session.get(
"https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300).content)
for part in range(3):
gdal.VectorTranslate(f"data/raw/parcels_{part + 1}.gpkg",
"/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml", layerName="parcels",
SQLStatement=f"SELECT INSPIREID FROM PREDEFINED LIMIT 100 OFFSET {part * 100}")
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, QgsExpression, QgsExpressionContext, QgsExpressionContextUtils,
QgsProcessingModelAlgorithm, QgsProcessingModelChildAlgorithm,
QgsProcessingModelChildParameterSource, QgsProcessingModelOutput,
QgsProcessingModelParameter, QgsProcessingParameterVectorLayer, QgsProject, QgsVectorLayer)
qgs = QgsApplication([], False)
qgs.initQgis()
profile = Path(QgsApplication.qgisSettingsDirPath())
model = QgsProcessingModelAlgorithm("schoolbuffer", "Models")
model.addModelParameter(QgsProcessingParameterVectorLayer("INPUT", "Input"), QgsProcessingModelParameter("INPUT"))
child = QgsProcessingModelChildAlgorithm("native:buffer")
child.setChildId("buf")
child.addParameterSources("INPUT", [QgsProcessingModelChildParameterSource.fromModelParameter("INPUT")])
child.addParameterSources("DISTANCE", [QgsProcessingModelChildParameterSource.fromStaticValue(500)])
output = QgsProcessingModelOutput("OUTPUT")
output.setChildId("buf")
output.setChildOutputName("OUTPUT")
child.setModelOutputs({"OUTPUT": output})
model.addChildAlgorithm(child)
model.updateDestinationParameters()
(profile / "processing/models").mkdir(parents=True, exist_ok=True)
model.toFile("schoolbuffer.model3")
model.toFile(str(profile / "processing/models/schoolbuffer.model3"))
from processing.core.Processing import Processing
import processing
Processing.initialize()
print("QGIS", Qgis.QGIS_VERSION, "| profile:", profile.relative_to(Path.cwd()))
QGIS 4.2.2-Belém do Pará | profile: config/profiles/default
Example 1: The same task at four rungs
Hectares for every parcel, four ways, on the three parcel files.
from qgis.core import QgsField, QgsVectorFileWriter
from qgis.PyQt.QtCore import QMetaType
import shutil
files = sorted(Path("data/raw").glob("*.gpkg"))
# 1. expression - per feature, as the Field Calculator would evaluate it
layer = QgsVectorLayer(f"{files[0]}|layername=parcels", "parcels", "ogr")
context = QgsExpressionContext(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
expression = QgsExpression("round($area / 10000, 2)")
first = next(layer.getFeatures())
context.setFeature(first)
print("1 expression: ", expression.evaluate(context))
# 2. console snippet - edit a copy of the layer in place
shutil.copy(files[0], "out/edited.gpkg")
edited = QgsVectorLayer("out/edited.gpkg|layername=parcels", "edited", "ogr")
edited.startEditing()
edited.addAttribute(QgsField("area_ha", QMetaType.Type.Double))
idx = edited.fields().indexOf("area_ha")
for f in edited.getFeatures():
edited.changeAttributeValue(f.id(), idx, round(f.geometry().area() / 10_000, 2))
edited.commitChanges()
print("2 snippet: ", next(edited.getFeatures())["area_ha"])
# 3. processing algorithm - reusable, in the Toolbox and the CLI
result = processing.run("native:fieldcalculator", {
"INPUT": f"{files[0]}|layername=parcels", "FIELD_NAME": "area_ha", "FIELD_TYPE": 0,
"FORMULA": "round($area / 10000, 2)", "OUTPUT": "out/parcels_1_ha.gpkg"})
print("3 algorithm: ", next(QgsVectorLayer(result["OUTPUT"], "r", "ogr").getFeatures())["area_ha"])
# 4. standalone-script shape - every file in a folder
totals = []
for path in files:
out = processing.run("native:fieldcalculator", {
"INPUT": f"{path}|layername=parcels", "FIELD_NAME": "area_ha", "FIELD_TYPE": 0,
"FORMULA": "round($area / 10000, 2)", "OUTPUT": f"out/{path.stem}_ha.gpkg"})["OUTPUT"]
totals.append(sum(f["area_ha"] for f in QgsVectorLayer(out, "o", "ogr").getFeatures()))
print("4 folder loop: ", len(files), "files,", [round(t, 1) for t in totals], "ha")
1 expression: 32.11
2 snippet: 32.11
3 algorithm: 32.11
4 folder loop: 3 files, [399.5, 59.7, 377.3] ha
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/rutland.gpkg"
DISTANCE = 25
layer = iface.activeLayer()
...
# after: the same logic, parameterised
class BufferWithinBoundary(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", "Result"))
...
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
def run_anything(ident: str, params: dict):
"""Built-in algorithms, models and script algorithms all resolve the same way."""
registry = QgsApplication.processingRegistry()
if registry.algorithmById(ident) is None:
raise ValueError(f"{ident} is not registered "
f"(providers: {[p.id() for p in registry.providers()]})")
return processing.run(ident, params)
schools_uri = "data/schools.gpkg|layername=schools"
for ident, params in {
"native:buffer": {"INPUT": schools_uri, "DISTANCE": 500, "SEGMENTS": 8, "OUTPUT": "TEMPORARY_OUTPUT"},
"model:schoolbuffer": {"INPUT": schools_uri, "buf:OUTPUT": "TEMPORARY_OUTPUT"},
"script:bufferwithinboundary": {"INPUT": schools_uri, "BOUNDARY": "data/district.geojson",
"DISTANCE": 500, "OUTPUT": "TEMPORARY_OUTPUT"},
"parceltools:bufferwithinboundary": {"INPUT": schools_uri, "BOUNDARY": "data/district.geojson",
"DISTANCE": 500, "OUTPUT": "TEMPORARY_OUTPUT"},
}.items():
out = next(v for k, v in run_anything(ident, params).items() if k.endswith("OUTPUT"))
print(f"{ident:34} {out.featureCount()} features, {sum(f.geometry().area() for f in out.getFeatures()) / 1e6:.2f} km²")
native:buffer 26 features, 31.93 km²
model:schoolbuffer 26 features, 31.71 km²
script:bufferwithinboundary 26 features, 31.92 km²
parceltools:bufferwithinboundary 26 features, 31.92 km²
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. The areas also show the three are not the same operation. The script algorithm clips to the county and the plain buffer does not; with both drawing arcs from 8 segments, the difference — 0.01 km² — is all that lies over the line, because at 500 m Rutland's schools barely reach its border. The model's buffer uses native:buffer's default of 5 segments per quarter circle and comes out smaller again — a setting nobody chose, baked into the model.
Example 4: Where each one lives on disk
default = subprocess.run([sys.executable, "-c",
"from qgis.core import QgsApplication; a = QgsApplication([], False); a.initQgis(); "
"print(QgsApplication.qgisSettingsDirPath())"],
capture_output=True, text=True, env={k: v for k, v in os.environ.items() if k != "QGIS_CUSTOM_CONFIG_PATH"})
home = str(Path.home())
print("a standalone script's profile:", default.stdout.strip().replace(home, "~"))
print("QGIS Desktop's profile: ", "~/.local/share/QGIS/QGIS4/profiles/default",
"(exists)" if Path(home, ".local/share/QGIS/QGIS4/profiles/default").is_dir() else "")
print("this page's profile: ", profile.relative_to(Path.cwd()))
for name, sub in {
"processing scripts": "processing/scripts",
"models": "processing/models",
"plugins": "python/plugins",
"expression functions": "python/expressions",
}.items():
path = profile / sub
listing = sorted(p.name for p in path.iterdir() if p.suffix) if path.exists() else []
print(f" {name:22} {sub:22} {listing if listing else '(none)'}")
a standalone script's profile: ~/.local/share/profiles/default/
QGIS Desktop's profile: ~/.local/share/QGIS/QGIS4/profiles/default (exists)
this page's profile: config/profiles/default
processing scripts processing/scripts ['buffer_within_boundary.py']
models processing/models ['schoolbuffer.model3']
plugins python/plugins (none)
expression functions python/expressions ['__init__.py']
Knowing the profile layout matters for deployment, and so does knowing whose profile a script gets. A standalone script created with QgsApplication([], False) did not use QGIS Desktop's profile at all: it got one of its own, under ~/.local/share/profiles/. So a model or script algorithm an analyst saved from the desktop is invisible to a scheduled script running as the same user, on the same machine. Point QGIS_CUSTOM_CONFIG_PATH at a profile you control, or run models by file.
The page's QGIS session ends here:
QgsProject.instance().clear()
qgs.exitQgis()
print("QGIS closed")
QGIS closed
Hooks designed for different people
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, in data-defined symbology and in a headless QgsExpression — 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 means its interface cannot run on a server, though a Processing provider it ships can.
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.
iface, profiles and model files
- Plugins need
iface; scripts must not use it: anything referencingifacecannot run headless. - Script algorithms live in a profile — and a headless script has its own: copy them to the profile the job uses, or set
QGIS_CUSTOM_CONFIG_PATH. - Models are
.model3files:qgis_processruns them by path; from Python, load them withQgsProcessingModelAlgorithm.fromFile(). - Model output names include the child id:
buf:OUTPUT, notOUTPUT. - 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 — QGIS 4 moved to Qt 6 and scoped enums such as
Qgis.LayerType.Vector— 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, data-defined symbology and QgsExpression in a headless script.
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. Remember that a standalone script uses its own profile, not QGIS Desktop's, so a script algorithm installed from the desktop is not visible to it.
Can a plugin run on a server?
Not its interface: buttons, panels and map tools depend on iface and a running GUI. A Processing provider inside the plugin is different — a headless script can import and register it, which is how QGIS's own GRASS plugin supplies algorithms to scripts. 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.