How to Automate QGIS with Python (PyQGIS): The Complete Workflow
On this page
Everything you have ever clicked in QGIS is a Python call underneath. The buffer dialog, the reprojection, the layer you dragged in, the style you picked, the layout you exported — all of it is the same API that is sitting one import away. The difficulty is almost never the API. It is that import qgis fails in the virtualenv you normally work in, that the same script behaves differently in three different places it can run, and that nobody tells you which of those three you are in. This guide fixes the environment first, then the object model, then shows the shape of a script that runs unattended.
Problem statement
You have a QGIS workflow that works when you do it by hand, and you want it to run without you. The obstacles are usually these:
import qgisfails. Your ordinary Python environment cannot see the QGIS libraries at all, andpip install qgisdoes not exist as a solution.- Three places to run code, three sets of rules. The built-in Python Console, a Processing script, and a standalone
.pyfile differ in what is already initialised for you — and code copied from one to another fails in confusing ways. ifaceis undefined. Tutorials use it freely; it exists only inside the running desktop application.- The application never exits cleanly. A standalone script hangs, or crashes on the way out, because the QGIS application object was never cleaned up.
- Processing algorithms are not registered.
processing.run("native:buffer", …)raisesQgsProcessingException: Error: Algorithm native:buffer not foundbecause the provider was never initialised outside the GUI.
The goal is a script you can hand to cron or a CI runner that opens no window, loads real layers, runs real algorithms, writes real files, and exits cleanly with a status code. Everything below ran on QGIS 4.2 on Linux, headless, on Rutland: OpenStreetMap land use and schools from Geofabrik, and the county boundary from the ONS.
Quick answer
PyQGIS code can run in three environments. Pick deliberately, because the boilerplate differs.
For automation you want the third. The minimum standalone script is this:
%%writefile minimal.py
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") # no display: tell Qt before anything starts
from qgis.core import QgsApplication, QgsVectorLayer
QgsApplication.setPrefixPath("/usr", True) # your QGIS install prefix
qgs = QgsApplication([], False) # False = no GUI
qgs.initQgis()
try:
import processing # needs .../python/plugins on sys.path
from processing.core.Processing import Processing
Processing.initialize()
layer = QgsVectorLayer("data/landuse.gpkg|layername=landuse", "landuse", "ogr")
if not layer.isValid():
raise SystemExit("could not load layer")
result = processing.run("native:buffer", {
"INPUT": layer,
"DISTANCE": 25,
"SEGMENTS": 8,
"DISSOLVE": False,
"OUTPUT": "out/landuse_buffer.gpkg",
})
print(result["OUTPUT"])
finally:
qgs.exitQgis()
import subprocess, sys
run = subprocess.run([sys.executable, "minimal.py"], capture_output=True, text=True)
print(run.stdout.strip(), "| exit status", run.returncode)
out/landuse_buffer.gpkg | exit status 0
Run it with the Python that can see QGIS, not with the one on your PATH. If that raises ModuleNotFoundError: No module named 'qgis', the interpreter is the problem and this guide fixes it.
QGIS's own Python, the application object and Processing
Use the Python interpreter QGIS ships with
This is the single decision that unblocks everything else. QGIS embeds a Python interpreter and builds its bindings against the exact GDAL, PROJ, and Qt versions it was compiled with. A pip install cannot reproduce that, so you do not bring QGIS to your Python — you bring your script to the QGIS Python.
| Platform | Interpreter to use |
|---|---|
| Linux (system package) | python3 — the distro's QGIS package installs the bindings into the system dist-packages; a venv created with --system-site-packages sees them too |
| Windows | the OSGeo4W Shell, or the python-qgis batch file in the QGIS install's bin folder |
| macOS | the python3 inside the QGIS application bundle |
| Any (isolated) | conda create -n qgis -c conda-forge qgis then conda activate qgis |
The conda-forge route is worth knowing even if you are on Linux: it gives you a disposable environment with a matched QGIS, GDAL, and PROJ that you can pin in a lockfile and rebuild on a server. Confirm whichever you use in one line — this page's runs used a --system-site-packages virtualenv over the Ubuntu QGIS 4.2 packages:
print(subprocess.run([sys.executable, "-c", "import qgis.core; print(qgis.core.Qgis.QGIS_VERSION)"],
capture_output=True, text=True).stdout.strip())
4.2.2-Belém do Pará
Start and stop the application object
QgsApplication is not optional decoration. It initialises the provider registry, the CRS database, the expression engine, the symbology registry and the paths QGIS searches for resources.
from qgis.core import QgsApplication
QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False)
qgs.initQgis()
...
qgs.exitQgis()
The prefix path is where QGIS's shared resources live — /usr on Debian and Ubuntu packages, the apps/qgis or apps/qgis-ltr folder of a Windows install, Contents/MacOS inside the macOS bundle. The False in QgsApplication([], False) means "no GUI"; it does not mean "no display needed" — on a machine without one, set QT_QPA_PLATFORM=offscreen as the minimal script does, or Qt aborts while the application is being created.
Do not rely on what happens to work without initQgis(). On QGIS 4.2 a GeoPackage layer even opened before it — the file provider is found lazily — which is exactly the kind of accident that stops being true for a PostGIS connection, a plugin provider or the next release.
Always call exitQgis(), once, at the end. Wrap it so it runs even on failure:
qgs = QgsApplication([], False)
qgs.initQgis()
try:
main()
finally:
qgs.exitQgis()
Initialise Processing separately
The Processing framework is a plugin, not part of the core library, so it is not on the path of a standalone interpreter until you put it there (/usr/share/qgis/python/plugins on Linux packages), and not registered until you initialise it:
%%writefile no_processing_init.py
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import QgsApplication, QgsVectorLayer
qgs = QgsApplication([], False)
qgs.initQgis()
try:
import processing
layer = QgsVectorLayer("data/landuse.gpkg|layername=landuse", "landuse", "ogr")
try:
processing.run("native:buffer", {"INPUT": layer, "DISTANCE": 25, "OUTPUT": "TEMPORARY_OUTPUT"})
except Exception as error:
print("before Processing.initialize():", type(error).__name__, str(error).strip())
from processing.core.Processing import Processing
Processing.initialize()
providers = [p.id() for p in QgsApplication.processingRegistry().providers()]
print("after:", len(QgsApplication.processingRegistry().algorithms()), "algorithms from", providers)
from qgis.analysis import QgsNativeAlgorithms
print("registering native again:", QgsApplication.processingRegistry().addProvider(QgsNativeAlgorithms()))
finally:
qgs.exitQgis()
run = subprocess.run([sys.executable, "no_processing_init.py"], capture_output=True, text=True)
print(run.stdout.strip())
print([line for line in run.stderr.splitlines() if "Duplicate" in line])
before Processing.initialize(): QgsProcessingException Error: Algorithm native:buffer not found
after: 772 algorithms from ['3d', 'gdal', 'grass', 'model', 'native', 'pdal', 'project', 'qgis', 'script']
registering native again: False
['Logged warning: Duplicate provider native registered']
Processing.initialize() registers the native (C++) provider along with GDAL, GRASS, the script and model providers, which is what makes native:buffer, gdal:* and qgis:* resolvable. Older 3.x snippets also register QgsNativeAlgorithms() by hand; after initialize() that is refused — addProvider returns False and QGIS logs Duplicate provider native registered.
Learn the four objects that carry everything
QgsApplication— the runtime. One per process, started and stopped as above.QgsProject— the.qgzfile made programmable: layers, their styles, the layer tree, project CRS, layout manager.QgsProject.instance()is a singleton; a standalone script gets an empty one for free and canread()an existing project into it.QgsVectorLayer/QgsRasterLayer— one dataset plus its rendering. Constructed from a data source string, a display name, and a provider key ("ogr"for files,"postgres"for PostGIS,"memory"for scratch layers).QgsFeature— a row: an id, an attribute list, and aQgsGeometry. Iterate withlayer.getFeatures(), optionally filtered by aQgsFeatureRequest.
That is enough to read anything QGIS can read:
from qgis.core import QgsVectorLayer, QgsFeatureRequest
layer = QgsVectorLayer("data/landuse.gpkg|layername=landuse", "landuse", "ogr")
print(layer.isValid(), layer.featureCount(), layer.crs().authid())
req = QgsFeatureRequest().setFilterExpression("\"fclass\" = 'residential' AND $area > 400000")
for feat in layer.getFeatures(req):
print(feat["osm_id"], round(feat.geometry().area()))
missing = QgsVectorLayer("data/landuse.gpkg|layername=land_use", "typo", "ogr")
print("wrong layername:", missing.isValid(), missing.featureCount())
True 2405 EPSG:27700
181308102 771869
189450623 493430
234474342 488107
301807321 458577
521191913 3985127
890260734 445325
1220613834 499750
wrong layername: False -2
isValid() deserves a habit of its own. A wrong path or a mistyped layername= produces an invalid layer rather than an exception, and every operation on it then fails vaguely somewhere later — here it reports a feature count of -2, "unknown".
Do the actual work through Processing
Hand-written feature loops are fine for inspection and terrible for analysis. Anything the toolbox already does — buffer, clip, dissolve, join by location, zonal statistics, raster calculator — should be called as an algorithm, because that code is C++, tested, and handles the CRS and geometry edge cases you would otherwise rediscover.
import processing
buffered = processing.run("native:buffer", {
"INPUT": layer,
"DISTANCE": 25,
"SEGMENTS": 8,
"END_CAP_STYLE": 0,
"JOIN_STYLE": 0,
"DISSOLVE": False,
"OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]
clipped = processing.run("native:clip", {
"INPUT": buffered,
"OVERLAY": "data/district.geojson",
"OUTPUT": "out/landuse_buffer_clipped.gpkg",
})["OUTPUT"]
print(type(buffered).__name__, buffered.featureCount(), "->", clipped)
QgsVectorLayer 2405 -> out/landuse_buffer_clipped.gpkg
The output of one algorithm feeds straight into the next as an input — that is the whole composition story. Running Processing algorithms from Python covers finding algorithm ids, discovering parameter names, and reading the results dictionary properly.
Write results deliberately
"TEMPORARY_OUTPUT" gives you an in-memory layer that disappears with the process — ideal for intermediates. For anything you want to keep, pass a real path and let the extension pick the driver, as the clip above did.
When you are writing a layer you built yourself rather than an algorithm result, use the writer directly:
from qgis.core import QgsVectorFileWriter, QgsProject
opts = QgsVectorFileWriter.SaveVectorOptions()
opts.driverName = "GPKG"
opts.layerName = "landuse_clean"
error, message, path, layer_name = QgsVectorFileWriter.writeAsVectorFormatV3(
buffered, "out/landuse_clean.gpkg", QgsProject.instance().transformContext(), opts
)
print(error == QgsVectorFileWriter.WriterError.NoError, path, layer_name)
True out/landuse_clean.gpkg landuse_clean
The V3 suffix is not cosmetic — the older writeAsVectorFormat and V2 signatures (still present in QGIS 4.2) are deprecated and differ in arguments and return values, which is why so many copied snippets fail on a current QGIS. V3 returns a tuple of error code, message, path and layer name, and reports failure there rather than raising.
Make the script tell you what happened
An unattended script that prints nothing is unattended in the worst sense. Route QGIS's own messages into Python logging and give every run a summary line. On QGIS 4 the signal to connect is messageReceivedWithFormat; the older messageReceived, which most snippets use, is deprecated and on 4.2 never fired:
import logging
from qgis.core import QgsApplication, QgsMessageLog, Qgis
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s", force=True)
log = logging.getLogger("pyqgis")
received = {"messageReceived": 0, "messageReceivedWithFormat": 0}
def old_style(message, tag, level):
received["messageReceived"] += 1
def on_message(message, tag, level, text_format=None):
received["messageReceivedWithFormat"] += 1
if level >= Qgis.MessageLevel.Warning:
log.warning("[%s] %s", tag, message)
QgsApplication.messageLog().messageReceived.connect(old_style)
QgsApplication.messageLog().messageReceivedWithFormat.connect(on_message)
QgsMessageLog.logMessage("reprojection used a ballpark transform", "pipeline", Qgis.MessageLevel.Warning)
QgsApplication.processEvents()
print(received)
{'messageReceived': 0, 'messageReceivedWithFormat': 1}
Everything QGIS would have shown in the Log Messages panel now lands in your log file. From there the rest of the automation story is ordinary Python, and the automation and pipelines path applies unchanged: schedule it, retry the flaky parts, alert on failure.
Standalone scripts on Rutland's land use
The data used on this page
OpenStreetMap land use and points of interest for Rutland from Geofabrik's 1 January 2026 extract (© OpenStreetMap contributors, ODbL), reprojected to the British National Grid with GDAL, and Rutland's boundary from the ONS Local Authority Districts (December 2024) boundaries. QGIS is started once for the in-process examples; the whole-script examples run in their own processes.
import os
import subprocess
import sys
from pathlib import Path
import requests
from osgeo import gdal
gdal.UseExceptions()
Path("data").mkdir(exist_ok=True)
Path("out").mkdir(exist_ok=True)
if not Path("data/rutland.shp.zip").exists():
Path("data/rutland.shp.zip").write_bytes(requests.get(
"https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
for layer_name, member, where in [("landuse", "gis_osm_landuse_a_free_1.shp", None),
("schools", "gis_osm_pois_a_free_1.shp", "fclass = 'school'")]:
if not Path(f"data/{layer_name}.gpkg").exists():
gdal.VectorTranslate(f"data/{layer_name}.gpkg", f"/vsizip/data/rutland.shp.zip/{member}",
layerName=layer_name, dstSRS="EPSG:27700", reproject=True, where=where)
if not Path("data/district.geojson").exists():
Path("data/district.geojson").write_bytes(requests.get(
"https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Local_Authority_Districts_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
params={"where": "LAD24NM='Rutland'", "outFields": "LAD24NM", "outSR": 27700, "f": "geojson"},
timeout=120).content)
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import QgsApplication, Qgis
qgs = QgsApplication([], False)
qgs.initQgis()
from processing.core.Processing import Processing
Processing.initialize()
print("QGIS", Qgis.QGIS_VERSION)
QGIS 4.2.2-Belém do Pará
Example 1: A reusable standalone bootstrap
Put this in qgis_bootstrap.py and stop rewriting it.
%%writefile qgis_bootstrap.py
"""Start a headless QGIS runtime with Processing available."""
import os
import sys
from contextlib import contextmanager
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import QgsApplication
PREFIX = os.environ.get("QGIS_PREFIX_PATH", "/usr")
PLUGINS = os.environ.get("QGIS_PLUGIN_PATH", "/usr/share/qgis/python/plugins")
@contextmanager
def qgis_app(with_processing=True):
QgsApplication.setPrefixPath(PREFIX, True)
app = QgsApplication([], False)
app.initQgis()
try:
if with_processing:
if PLUGINS not in sys.path:
sys.path.append(PLUGINS)
from processing.core.Processing import Processing
Processing.initialize()
yield app
finally:
app.exitQgis()
Every script then starts the same way, and the cleanup is guaranteed — Example 5 is one.
Example 2: Open a project and work with its layers
Automation often means "reproduce what the analyst set up, on new data". Read their project and address layers by name. Here the project is one built a moment earlier, with the land use and the schools in it:
from qgis.core import QgsProject, QgsVectorLayer
project = QgsProject.instance()
for name in ("landuse", "schools"):
project.addMapLayer(QgsVectorLayer(f"data/{name}.gpkg|layername={name}", name.title(), "ogr"))
project.write("out/district.qgz")
project.clear()
if not project.read("out/district.qgz"):
raise SystemExit("could not read project")
for layer in project.mapLayers().values():
count = layer.featureCount() if layer.type() == Qgis.LayerType.Vector else ""
print(layer.name(), layer.crs().authid(), count)
found = project.mapLayersByName("Schools")
print(len(found), "layer called Schools;", len(project.mapLayersByName("schools")), "called schools")
schools = found[0]
Landuse EPSG:27700 2405
Schools EPSG:27700 26
1 layer called Schools; 0 called schools
mapLayersByName returns a list because names are not unique — and it is case-sensitive; take [0] only after you have checked the list is not empty. Reading a project does not require a GUI — the layer tree, styles and layout definitions all load headlessly.
Example 3: A memory layer for intermediate results
Scratch layers are the PyQGIS equivalent of a temporary DataFrame — no file, no cleanup.
from qgis.core import QgsFeature, QgsGeometry, QgsPointXY, QgsField
from qgis.PyQt.QtCore import QMetaType
pts = QgsVectorLayer("Point?crs=EPSG:27700", "sites", "memory")
pts.dataProvider().addAttributes([
QgsField("name", QMetaType.Type.QString),
QgsField("score", QMetaType.Type.Double),
])
pts.updateFields()
f = QgsFeature(pts.fields())
f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(486_000, 308_700))) # Oakham
f["name"] = "depot"
f["score"] = 4.5
print(pts.dataProvider().addFeature(f), pts.featureCount(), pts.crs().authid(),
[field.typeName() for field in pts.fields()])
pts.updateExtents()
True 1 EPSG:27700 ['string', 'double']
The URI carries the CRS, so a memory layer is never CRS-less — a small discipline that avoids the CRS mismatch class of bug entirely. Field types are QMetaType values in QGIS 4; QVariant.Double from older snippets is still accepted by 4.2.
Example 4: Editing attributes safely
Edits go through an edit buffer. Wrap them so a failure rolls back rather than half-applying:
from qgis.core import QgsField, QgsVectorLayer
import shutil
shutil.copy("data/schools.gpkg", "out/schools_edit.gpkg")
layer = QgsVectorLayer("out/schools_edit.gpkg|layername=schools", "schools", "ogr")
layer.dataProvider().addAttributes([QgsField("status", QMetaType.Type.QString)])
layer.updateFields()
layer.startEditing()
try:
idx = layer.fields().indexOf("status")
for feat in layer.getFeatures():
layer.changeAttributeValue(feat.id(), idx, "reviewed")
if not layer.commitChanges():
raise RuntimeError(layer.commitErrors())
except Exception:
layer.rollBack()
raise
reviewed = sum(1 for f in layer.getFeatures() if f["status"] == "reviewed")
print(f"{reviewed} of {layer.featureCount()} schools marked reviewed")
26 of 26 schools marked reviewed
For a bulk update on a file-based layer, layer.dataProvider().changeAttributeValues({fid: {idx: value}}) skips the buffer entirely — at the cost of being unundoable.
Example 5: The whole thing as one runnable job
%%writefile school_catchments.py
#!/usr/bin/env python3
"""Nightly: buffer the schools, dissolve, clip to the district, export."""
import logging
import sys
from pathlib import Path
from qgis_bootstrap import qgis_app
log = logging.getLogger("schools")
OUT = Path("out")
def main():
from qgis.core import QgsVectorLayer
import processing
schools = QgsVectorLayer("data/schools.gpkg|layername=schools", "schools", "ogr")
if not schools.isValid():
raise SystemExit("schools layer failed to load")
named = processing.run("native:extractbyexpression", {
"INPUT": schools,
"EXPRESSION": '"name" IS NOT NULL',
"OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]
buffered = processing.run("native:buffer", {
"INPUT": named, "DISTANCE": 500, "SEGMENTS": 16,
"DISSOLVE": True, "OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]
OUT.mkdir(exist_ok=True)
out = processing.run("native:clip", {
"INPUT": buffered,
"OVERLAY": "data/district.geojson",
"OUTPUT": str(OUT / "school_catchments.gpkg"),
})["OUTPUT"]
log.info("%d named schools of %d; wrote %s", named.featureCount(), schools.featureCount(), out)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)-7s %(message)s")
with qgis_app():
try:
main()
except BaseException:
log.exception("job failed")
sys.exit(1)
for script in ("school_catchments.py",):
run = subprocess.run([sys.executable, script], capture_output=True, text=True)
print(run.stderr.strip().splitlines()[-1], "| exit status", run.returncode)
INFO 26 named schools of 26; wrote out/school_catchments.gpkg | exit status 0
Three algorithms, one output, an exit code a scheduler can read. That is the shape almost every QGIS automation job converges on. Note except BaseException: SystemExit is not an Exception, and a bare except Exception would let the "failed to load" exit skip the log line.
QGIS is a library that ships with a GUI
The mental model that makes PyQGIS click is this: QGIS is a library that happens to ship with a GUI, not a GUI that happens to expose a scripting hook. When you run a standalone script you are using the same C++ objects the desktop uses, minus the parts that draw. That is why headless runs are not a degraded mode — they are the library used directly.
The three runtimes explain most of the confusion in tutorials. In the Python Console, the application is already running, Processing is already initialised, and iface gives you the desktop itself — the map canvas, the active layer, the message bar. In a Processing script, the application is running but iface is deliberately not something to rely on, because the same algorithm has to work from the model builder and from qgis_process on a server. In a standalone script, nothing is running until you start it and iface does not exist at all. Code that uses iface.activeLayer() therefore cannot be automated as written; the automatable form takes the layer as a parameter.
The second thing worth internalising is where the boundary between "PyQGIS" and "Processing" sits. PyQGIS is the object model: layers, features, geometry, styling, projects, layouts. Processing is the algorithm catalogue built on top of it — 772 algorithms on this installation. Most automation is mostly Processing calls and a little PyQGIS glue — load a layer, run a few algorithms, style the result, export a layout. When you find yourself writing a feature-by-feature loop to do something the toolbox names, stop; the algorithm will be faster and correct in the cases you have not thought of yet.
Finally, the reason to prefer QGIS over a pure-Python stack for a given job is usually specific algorithms or specific rendering. Network analysis, raster terrain analysis, the GRASS provider, layer styling, and print layouts have no clean equivalent in the GeoPandas world. The reason to prefer GeoPandas is usually environment and iteration speed: a virtualenv, a notebook, a DataFrame API. PyQGIS vs GeoPandas works through that decision properly, and moving data between the two covers the common answer, which is to use both.
Prefix paths, object lifetimes and QGIS 4 changes
The prefix path is not the same as the install path
setPrefixPath wants the directory containing share/qgis, not the directory containing the executable. On Debian and Ubuntu that is /usr, not /usr/bin. The failure is quieter than you might expect:
%%writefile wrong_prefix.py
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import (QgsApplication, QgsCoordinateReferenceSystem, QgsCoordinateTransform,
QgsProject, QgsPointXY)
QgsApplication.setPrefixPath("/usr/bin", True)
qgs = QgsApplication([], False)
qgs.initQgis()
to_bng = QgsCoordinateTransform(QgsCoordinateReferenceSystem("EPSG:4326"),
QgsCoordinateReferenceSystem("EPSG:27700"), QgsProject.instance())
print("reprojection still works:", to_bng.transform(QgsPointXY(-0.7263, 52.6698)))
print("SVG symbols searched in:", QgsApplication.svgPaths()[0], "exists:", os.path.isdir(QgsApplication.svgPaths()[0]))
qgs.exitQgis()
print(subprocess.run([sys.executable, "wrong_prefix.py"], capture_output=True, text=True).stdout.strip())
reprojection still works: <QgsPointXY: POINT(486226.95358908321941271 308770.95728359126951545)>
SVG symbols searched in: /usr/bin/share/qgis/svg/ exists: False
On QGIS 4.2 the CRS machinery comes from PROJ's own database, so reprojection carries on; what breaks is everything QGIS looks up under its prefix — here the SVG marker library, so point symbols that use SVGs render as missing-image placeholders.
Layers must outlive the objects that use them
QgsVectorLayer owns its data provider. Hand out the provider and let the layer go, and the provider goes with it:
import gc
def provider_of(path):
layer = QgsVectorLayer(path, "landuse", "ogr")
return layer.dataProvider() # the layer's last reference ends here
provider = provider_of("data/landuse.gpkg|layername=landuse")
gc.collect()
try:
provider.featureCount()
except RuntimeError as error:
print("RuntimeError:", error)
RuntimeError: wrapped C/C++ object of type QgsVectorDataProvider has been deleted
Keep a Python reference to the layer for as long as anything uses it, or add the layer to QgsProject.instance(), which takes ownership.
QMetaType for fields in QGIS 4
QgsField("score", QMetaType.Type.Double) is the QGIS 4 form; QGIS 3 snippets use QVariant.Double, which 4.2 still accepts. Import either from qgis.PyQt.QtCore, which is a shim over whichever PyQt the build uses — PyQt6 in QGIS 4 — never from PyQt5 or PyQt6 directly, or your script breaks on the next major release.
Threading
QGIS objects are not thread-safe in general, and the Processing framework expects to be driven from one thread. To parallelise, run several processes — each with its own QgsApplication, started once per worker — rather than several threads. That pattern is covered in speeding up batch GIS jobs with parallel processing.
Version differences are real
The API changes between major versions: QGIS 4 moved to Qt 6, scoped its enums (QgsFeatureRequest.Flag.NoGeometry, Qgis.LayerType.Vector) and deprecated signals such as messageReceived. Pin the QGIS version your automation runs against, and prefer the LTR release for anything scheduled.
qgis_process may be all you need
If the job really is "run one algorithm on one file", QGIS ships a command-line runner and you can skip Python altogether:
cli = subprocess.run(["qgis_process", "run", "native:buffer", "--",
"INPUT=data/landuse.gpkg", "DISTANCE=25", "OUTPUT=out/buf.gpkg"],
capture_output=True, text=True)
print("exit status", cli.returncode, "|", [line for line in cli.stdout.splitlines() if line.startswith("OUTPUT")])
listing = subprocess.run(["qgis_process", "list"], capture_output=True, text=True).stdout
print(sum(1 for line in listing.splitlines() if line.strip().startswith("native:")), "native algorithms listed")
exit status 0 | ['OUTPUT:\tout/buf.gpkg', 'OUTPUT:\tout/buf.gpkg']
325 native algorithms listed
It is also the fastest way to discover ids and parameter names: qgis_process list and qgis_process help native:buffer.
The page's session ends the way every script should, with the project cleared before the application goes:
QgsProject.instance().clear()
qgs.exitQgis()
print("QGIS closed")
QGIS closed
Internal links
- How to Run QGIS Processing Algorithms from Python
- How to Run a PyQGIS Script Headless Without Opening QGIS
- How to Batch Process Layers in QGIS with PyQGIS
- How to Run Field Calculator Expressions from 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 Style Layers and Export Map Layouts from PyQGIS
- How to Move Data Between QGIS and GeoPandas
- PyQGIS vs GeoPandas: Which to Use for GIS Automation
- How to Fix "ModuleNotFoundError: No module named 'qgis'"
- How to Make a GIS Workflow Reproducible in Python
FAQ
Can I install PyQGIS with pip?
No — and the packages on PyPI with promising names are not the QGIS bindings. The bindings are compiled against the specific Qt, GDAL, and PROJ builds that ship with QGIS, so they arrive with QGIS itself: a system package, the official installer, or conda install -c conda-forge qgis. The conda route is the closest thing to a pip-style install and is the one to use when you need a reproducible environment on a server.
What is iface and why is it None in my script?
iface is the interface to the running desktop application — the canvas, toolbars, active layer, and message bar. It is injected into the Python Console and into plugins; a standalone script has no desktop, so there is nothing for it to be, and a Processing algorithm must not rely on it because the same algorithm runs from qgis_process. Any code you intend to automate must take its inputs as parameters rather than reading them from iface.activeLayer().
Do I need QGIS Desktop installed on the server?
You need the QGIS libraries, which in practice means installing the QGIS package — but you never start the desktop. On a headless Linux box install qgis (or use the official Docker image), set QT_QPA_PLATFORM=offscreen, and run your script; no X server is required. Running PyQGIS headless covers the exact incantations and the errors that appear when one is missing.
Why does my script segfault at the end?
Usually a missing exitQgis(), a second QgsApplication in the same process, or code still holding objects whose layer has gone — which in QGIS 4.2 raises RuntimeError: wrapped C/C++ object … has been deleted when it is caught in time. Call exitQgis() once in a finally block, keep references to layers alive until you are done with them, and do not let the QgsApplication object itself fall out of scope while work is still running.
Should I use Processing algorithms or write my own feature loops?
Use algorithms for anything the toolbox already names. They are C++, they handle mixed geometry types and CRS transforms correctly, they report progress, and they are the same code the desktop runs, so results match what your colleagues see. Write feature loops for inspection, for attribute logic with no algorithm equivalent, and for gluing algorithm outputs together.
How do I find the id of the algorithm behind a dialog I use?
Run it once from the toolbox, then open Processing → History: every run is recorded as the exact processing.run() call, id and parameter dictionary included. That is the fastest route from "the thing I click" to "the thing I can script", and it is covered step by step in running Processing algorithms from Python.
Can PyQGIS scripts run in a Jupyter notebook?
Yes, if the kernel runs the QGIS Python. Create a conda environment with qgis and jupyter together, or register the QGIS interpreter as a kernel. Start the QgsApplication once in the first cell and leave it running for the session — constructing a second one in the same process is not supported.