How to Automate QGIS with Python (PyQGIS): The Complete Workflow
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 and isNoneeverywhere else.- The application never exits. A standalone script hangs, or segfaults 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.
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:
import sys
from qgis.core import QgsApplication, QgsVectorLayer
QgsApplication.setPrefixPath("/usr", True) # your QGIS install prefix
qgs = QgsApplication([], False) # False = no GUI
qgs.initQgis()
sys.path.append("/usr/share/qgis/python/plugins") # so `import processing` works
from processing.core.Processing import Processing
import processing
Processing.initialize()
layer = QgsVectorLayer("data/parcels.gpkg|layername=parcels", "parcels", "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": "data/parcels_buffer.gpkg",
})
print(result["OUTPUT"])
qgs.exitQgis()
Run it with the Python that ships with 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.
Step-by-step solution
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 |
| Windows | the OSGeo4W Shell, then python-qgis.bat, or C:\Program Files\QGIS 3.xx\bin\python-qgis-ltr.bat |
| macOS | /Applications/QGIS.app/Contents/MacOS/bin/python3 |
| Any (isolated) | conda create -n qgis -c conda-forge qgis python=3.11 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 it in one line:
python -c "import qgis.core; print(qgis.core.Qgis.QGIS_VERSION)"
Start and stop the application object
QgsApplication is not optional decoration. It initialises the providers that read data, the CRS database, the expression engine, and the symbology registry. Without it, QgsVectorLayer returns an invalid layer for a file that is perfectly fine.
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 most Linux packages, C:\Program Files\QGIS 3.xx\apps\qgis-ltr on Windows, /Applications/QGIS.app/Contents/MacOS on macOS. The False in QgsApplication([], False) means "no GUI"; pass True only if you genuinely intend to show Qt widgets.
Always call exitQgis(). Skipping it is the usual cause of a script that finishes its work and then segfaults, because C++ objects are still holding references when the interpreter tears down. 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.
import sys
sys.path.append("/usr/share/qgis/python/plugins")
from processing.core.Processing import Processing
import processing
Processing.initialize()
Processing.initialize() registers the native (C++) provider along with GDAL and the script providers, which is what makes native:buffer, gdal:*, and qgis:* resolvable. On older 3.x builds you may also see this pattern, which registers the native provider explicitly:
from qgis.analysis import QgsNativeAlgorithms
QgsApplication.processingRegistry().addProvider(QgsNativeAlgorithms())
On a current QGIS this is redundant β initialize() already did it β and registering the same provider twice logs a warning. Add it only if Processing.initialize() alone leaves your algorithms missing.
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/parcels.gpkg|layername=parcels", "parcels", "ogr")
print(layer.isValid(), layer.featureCount(), layer.crs().authid())
req = QgsFeatureRequest().setFilterExpression('"area_m2" > 500')
for feat in layer.getFeatures(req):
print(feat["parcel_id"], feat.geometry().area())
isValid() deserves a habit of its own. A wrong path, a missing layername=, or an uninitialised application all produce an invalid layer rather than an exception, and every operation on it then fails vaguely somewhere later.
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.
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/parcels_buffer_clipped.gpkg",
})["OUTPUT"]
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 or scratch 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:
processing.run("native:buffer", {..., "OUTPUT": "out/parcels_buffer.gpkg"})
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 = "parcels_clean"
QgsVectorFileWriter.writeAsVectorFormatV3(
layer, "out/parcels_clean.gpkg",
QgsProject.instance().transformContext(), opts
)
The V3 suffix is not cosmetic β the older writeAsVectorFormat and V2 signatures are deprecated and differ in argument order, which is why so many copied snippets fail on a current QGIS.
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.
import logging
from qgis.core import QgsApplication, Qgis
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s",
)
log = logging.getLogger("pyqgis")
def on_message(message, tag, level):
if level >= Qgis.Warning:
log.warning("[%s] %s", tag, message)
QgsApplication.messageLog().messageReceived.connect(on_message)
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.
Code examples
Example 1: A reusable standalone bootstrap
Put this in qgis_bootstrap.py and stop rewriting it.
"""Start a headless QGIS runtime with Processing available."""
import os
import sys
from contextlib import contextmanager
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:
from qgis_bootstrap import qgis_app
import processing
with qgis_app():
processing.run("native:buffer", {...})
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.
from qgis.core import QgsProject
project = QgsProject.instance()
if not project.read("projects/district.qgz"):
raise SystemExit("could not read project")
for layer in project.mapLayers().values():
print(layer.name(), layer.crs().authid(), layer.featureCount() if layer.type() == 0 else "")
parcels = project.mapLayersByName("Parcels")[0]
mapLayersByName returns a list because names are not unique; take [0] only after you have checked the list is not empty. Note that 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 QgsVectorLayer, QgsFeature, QgsGeometry, QgsPointXY, QgsField
from qgis.PyQt.QtCore import QVariant
pts = QgsVectorLayer("Point?crs=EPSG:27700", "sites", "memory")
pts.dataProvider().addAttributes([
QgsField("name", QVariant.String),
QgsField("score", QVariant.Double),
])
pts.updateFields()
f = QgsFeature(pts.fields())
f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(529_000, 181_000)))
f["name"] = "depot"
f["score"] = 4.5
pts.dataProvider().addFeature(f)
pts.updateExtents()
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.
Example 4: Editing attributes safely
Edits go through an edit buffer. Wrap them so a failure rolls back rather than half-applying.
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
For a bulk update on a file-based layer, layer.dataProvider().changeAttributeValues({fid: {idx: value}}) skips the buffer entirely and is considerably faster β at the cost of being unundoable.
Example 5: The whole thing as one runnable job
#!/usr/bin/env python3
"""Nightly: buffer active depots, clip to district, export."""
import logging
import sys
from pathlib import Path
from qgis_bootstrap import qgis_app
from qgis.core import QgsVectorLayer
import processing
log = logging.getLogger("depots")
OUT = Path("out")
def main():
depots = QgsVectorLayer("data/depots.gpkg|layername=depots", "depots", "ogr")
if not depots.isValid():
raise SystemExit("depots layer failed to load")
selected = processing.run("native:extractbyexpression", {
"INPUT": depots,
"EXPRESSION": '"status" = \'active\'',
"OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]
buffered = processing.run("native:buffer", {
"INPUT": selected, "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 / "depot_catchments.gpkg"),
})["OUTPUT"]
log.info("wrote %s", out)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(message)s")
with qgis_app():
try:
main()
except Exception:
log.exception("job failed")
sys.exit(1)
Three algorithms, one output, an exit code a scheduler can read. That is the shape almost every QGIS automation job converges on.
Explanation
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. qgis.core has no dependency on windows at all. 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 absent, 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. Most automation is 90% Processing calls and 10% PyQGIS glue β load a layer, run four 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, GRASS and SAGA providers, 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.
Edge cases or notes
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 or /usr/share/qgis. Getting it wrong produces a running application with no CRS database, which then fails on the first reprojection with a message about proj that points nowhere useful.
Layers must outlive the variables that made them
QgsVectorLayer is reference-counted on the C++ side. A layer created inside a function and passed to an algorithm can be garbage-collected mid-run, producing a crash rather than an exception. Keep a Python reference alive for the duration, or add the layer to QgsProject.instance() which takes ownership.
QVariant types, not Python types, for fields
QgsField("score", QVariant.Double) β not float. The imports come from qgis.PyQt.QtCore, which is a shim that works whether the build uses PyQt5 or PyQt6. Import from qgis.PyQt, never from PyQt5 directly, or your script breaks on the next QGIS 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 β rather than several threads. That pattern is covered in speeding up batch GIS jobs with parallel processing, and applies unchanged here.
Version differences are real
The API is stable within 3.x but not frozen: writeAsVectorFormatV3, QgsProcessingParameterFeatureSink behaviours, and several Processing algorithm ids changed during the series. 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:
qgis_process run native:buffer -- INPUT=data/parcels.gpkg DISTANCE=25 OUTPUT=out/buf.gpkg
It is also the fastest way to discover ids and parameter names: qgis_process list and qgis_process help native:buffer.
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 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, and it does not exist in a standalone script or inside a Processing algorithm, because neither has a window. 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?
Almost always a missing exitQgis(), or Python garbage-collecting a layer whose C++ object is still in use. Call exitQgis() 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.
Is PyQGIS a replacement for GeoPandas?
No, they solve different problems and read the same files. GeoPandas is better for tabular work, quick iteration, and anything that has to run in a plain virtualenv. PyQGIS is better when you need QGIS-specific algorithms, styling, or print layouts. Many good pipelines do the tabular work in GeoPandas and hand the result to QGIS for the rendering β see moving data between QGIS and GeoPandas.