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 qgis fails. Your ordinary Python environment cannot see the QGIS libraries at all, and pip install qgis does 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 .py file differ in what is already initialised for you — and code copied from one to another fails in confusing ways.
  • iface is 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", …) raises QgsProcessingException: Error: Algorithm native:buffer not found because 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.

Three places PyQGIS code runs, and what each one starts for you. three places PyQGIS code runsPython ConsoleProcessing scriptStandalone scriptQgsApplicationalready runningalready running✓you start itProcessing readyyesyes✓you initialiseiface existsyes✕no✕noruns unattended✕no!as a child✓yesAutomation lives in the third column — nothing is running until you start it spatialworkflow.io
The API is identical in all three; what differs is how much has already been started for you.

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

The PyQGIS object stack from application down to geometry. five levels, three you touch dailyQgsApplicationthe runtimeQgsProjectlayers, styles, layoutsQgsVectorLayerone dataset + renderingQgsFeatureid + attributesQgsGeometrythe shape itselfEach level owns one thing and hands the next one down spatialworkflow.io
Five levels, and almost every script only ever touches three of them.
  • QgsApplication — the runtime. One per process, started and stopped as above.
  • QgsProject — the .qgz file 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 can read() 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 a QgsGeometry. Iterate with layer.getFeatures(), optionally filtered by a QgsFeatureRequest.

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á
Three details in one layer construction, and the check that has to follow it. a bad layer is invalid, not an exceptionlayer = QgsVectorLayer("parcels.gpkg|layername=parcels","parcels", "ogr")assert layer.isValid()①②③①Without it you get the first layer in the container, which may be a lookuptable rather than the one you wanted.②The provider key: ogr for files, postgres for PostGIS, memory for scratchlayers. The wrong one gives an invalid layer.③A bad path, a missing layername, or an application that was neverinitialised all produce an invalid layer and no exception.Every operation on an invalid layer then fails vaguely, a long way from the cause spatialworkflow.io
Every example below asserts isValid before doing anything with the layer.

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 six phases of a standalone PyQGIS script. four lines of ceremony at each end1setPrefixPath("/usr")where share/qgis lives2QgsApplication([], False)no GUI subsystem3initQgis()providers + CRS db4Processing.initialize()native: algorithms5your workflowload, run, write6exitQgis()in a finally blockEverything in between is the part that is actually yours spatialworkflow.io
Four lines of ceremony at each end; everything in between is your workflow.

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

Three PyQGIS mistakes whose symptom points somewhere else, as measured on QGIS 4.2. three symptoms that point the wrong waythe symptomthe causeSVG markers missing, CRS finesetPrefixPath wants /usr, not/usr/binRuntimeError: object deletedkeep a Python reference to thelayerQGIS messages never loggedQGIS 4: connectmessageReceivedWithFormatEach one surfaces far from the line that caused it spatialworkflow.io
Measured on this page under QGIS 4.2. None of them fails where the mistake was made.

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

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.