The PyQGIS API Map: Which Class Does What
On this page
Problem statement
The PyQGIS API has well over a thousand classes, all called Qgs-something, and the documentation is a reference rather than a map. So the first hour of any PyQGIS task is usually spent working out which of these you need:
QgsProject QgsVectorLayer QgsFeature QgsGeometry
QgsMapLayer QgsVectorDataProvider QgsFeatureRequest QgsPointXY
QgsLayerTreeLayer QgsFields QgsExpression QgsCoordinateTransform
QgsMapSettings QgsPrintLayout QgsProcessingContext QgsSymbol
Most everyday automation touches perhaps fifteen of them, and they fit into six groups with a clear containment relationship. Once you can see that structure, "which class do I need?" usually answers itself β and the naming conventions let you guess correctly for the rest.
Every snippet below ran on QGIS 4.2 in headless mode, on Rutland: HM Land Registry's freehold parcels, OpenStreetMap land use from Geofabrik's 1 January 2026 extract, and a Copernicus 30 m elevation model.
Quick answer
Six groups, in containment order:
- Application β
QgsApplicationstarts everything;QgsProjectholds the document - Layers β
QgsVectorLayer,QgsRasterLayer, and theQgsDataProviderunderneath each - Data β
QgsFeature,QgsGeometry,QgsFields,QgsFeatureRequest - Reference systems β
QgsCoordinateReferenceSystem,QgsCoordinateTransform - Presentation β the layer tree, renderers, symbols, layouts
- Processing β
processing.run,QgsProcessingContext,QgsProcessingFeedback
from qgis.core import (
QgsApplication, QgsProject, QgsVectorLayer, QgsFeature, QgsGeometry,
QgsPointXY, QgsField, QgsFeatureRequest, QgsExpression,
QgsCoordinateReferenceSystem, QgsCoordinateTransform,
)
# application β project β layer β features β geometry
layer = QgsVectorLayer("data/landuse.gpkg|layername=landuse", "landuse", "ogr")
QgsProject.instance().addMapLayer(layer)
request = QgsFeatureRequest(QgsExpression("\"fclass\" = 'residential' AND $area > 400000"))
for feature in layer.getFeatures(request):
geometry = feature.geometry()
print(feature["osm_id"], feature["name"], round(geometry.area()), geometry.centroid().asPoint())
181308102 None 771869 <QgsPointXY: POINT(486604.70234285679180175 299929.90172930393600836)>
189450623 None 493430 <QgsPointXY: POINT(484227.25551938044372946 311078.21459612977923825)>
234474342 None 488107 <QgsPointXY: POINT(503573.92312587687047198 310831.25306442152941599)>
301807321 None 458577 <QgsPointXY: POINT(482833.02459808747516945 314283.17874584026867524)>
521191913 None 3985127 <QgsPointXY: POINT(502280.68348238454200327 307623.18062421015929431)>
890260734 None 445325 <QgsPointXY: POINT(485488.0088054976076819 308175.65898404584731907)>
1220613834 None 499750 <QgsPointXY: POINT(497932.9334501480916515 304508.2767200701055117)>
A handful of lines that touch five of the six groups β which is roughly the shape of most PyQGIS scripts.
The containment map
The classes, from application to Processing
Application and project
The setup at the end of the page started QGIS exactly like this β once, because a process can only run one QgsApplication, and calling exitQgis() and starting another in the same process crashes:
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") # before QgsApplication: no display needed
from qgis.core import QgsApplication, QgsProject
qgs = QgsApplication([], False) # False = no GUI
qgs.initQgis()
...
QgsProject.instance().clear()
qgs.exitQgis() # once, at the very end
project = QgsProject.instance() # a singleton β one per process
project.setCrs(QgsCoordinateReferenceSystem("EPSG:27700"))
print(project is QgsProject.instance(), project.crs().authid(), len(project.mapLayers()), "layers")
project.write("data/out/atlas.qgz")
project.clear()
print(len(project.mapLayers()), "layers after clear()")
from qgis.PyQt import sip
print("the layer object from the quick answer is deleted:", sip.isdeleted(layer))
layer = QgsVectorLayer("data/landuse.gpkg|layername=landuse", "landuse", "ogr") # so open it again
True EPSG:27700 1 layers
0 layers after clear()
the layer object from the quick answer is deleted: True
clear() did not just forget the layer: the project owned it and deleted it, and the Python name layer now points at a dead C++ object β using it would raise or crash. QgsApplication is the runtime: it owns the provider registry, the CRS database, the Processing registry and the style manager. QgsProject is the document: layers, their styles, the layer tree, layouts and settings. The singleton nature of both is worth internalising β a script that builds two projects must clear() between them.
Layers and providers
from qgis.core import QgsVectorLayer, QgsRasterLayer
vector = QgsVectorLayer("data/parcels.gpkg|layername=parcels", "parcels", "ogr")
raster = QgsRasterLayer("data/dem.tif", "dem", "gdal")
print(vector.isValid(), vector.featureCount(), vector.crs().authid())
print(vector.fields().names())
print(vector.extent().toString(1))
print(vector.geometryType().name, vector.wkbType().name)
print(raster.isValid(), raster.width(), "x", raster.height(), raster.crs().authid())
provider = vector.dataProvider() # the connection to the source
print(provider.name(), provider.capabilitiesString())
True 21253 EPSG:27700
['fid', 'INSPIREID']
479000.6,292380.6 : 506878.4,319200.7
Polygon Polygon
True 933 x 933 EPSG:27700
ogr Add Features, Delete Features, Change Attribute Values, Add Attributes, Delete Attributes, Rename Attributes, Create Spatial Index, Create Attribute Indexes, Fast Access to Features at ID, Change Geometries, Transactions, Curved Geometries
The split is consistent: the layer owns rendering, selection, the edit buffer and styling; the provider owns the connection to the data. Anything about how data is fetched or written lives on the provider; anything about how it looks lives on the layer.
Features, fields and geometry
from qgis.core import QgsFeature, QgsGeometry, QgsPointXY
feature = QgsFeature(layer.fields()) # correct field order, guaranteed
feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(486000, 308700)))
feature["name"] = "New site"
print(feature.id()) # not yet assigned: the provider does that on add
print(feature.attributes()) # values, in field order
print(feature.fields().names()) # the schema
print(feature.geometry().asWkt())
-9223372036854775808
[None, None, None, None, 'New site']
['fid', 'osm_id', 'code', 'fclass', 'name']
Point (486000 308700)
The geometry API is where most of the useful methods live. Some methods return a new geometry; others change the geometry in place and return only a status code:
biggest = max(layer.getFeatures(), key=lambda f: f.geometry().area())
geometry = QgsGeometry(biggest.geometry())
print(biggest["fclass"], round(geometry.area()), "mΒ²;", round(geometry.length()), "m of boundary")
buffered = geometry.buffer(25, 8) # returns a new geometry
print("buffer returned", type(buffered).__name__, "β original still", round(geometry.area()), "mΒ²")
status = geometry.translate(100, 0) # changes this geometry, returns a status code
print("translate returned", status, "β geometry moved to", geometry.boundingBox().xMinimum().__round__())
print(geometry.isGeosValid(), geometry.makeValid().isGeosValid(), geometry.asJson()[:60])
farmland 11399161 mΒ²; 19207 m of boundary
buffer returned QgsGeometry β original still 11399161 mΒ²
translate returned 0 β geometry moved to 483240
True True {"coordinates":[[[483240.03968346305,313023.89783837023],[48
Mixing the two families up produces code that appears to do nothing: geometry.buffer(25) on its own line computes a buffer and throws it away.
Requesting features efficiently
import time
from qgis.core import QgsFeatureRequest, QgsExpression, QgsRectangle
def timed(request, label):
start = time.perf_counter()
n = sum(1 for _ in vector.getFeatures(request))
print(f"{label:34} {n:>6,} features {time.perf_counter() - start:.2f} s")
timed(QgsFeatureRequest(), "everything")
timed(QgsFeatureRequest(QgsRectangle(484000, 307500, 487000, 310000)), "bounding box (Oakham)")
timed(QgsFeatureRequest(QgsExpression("$area > 100000")), "expression: over 10 ha")
timed(QgsFeatureRequest()
.setSubsetOfAttributes(["INSPIREID"], vector.fields())
.setFlags(QgsFeatureRequest.Flag.NoGeometry), "one attribute, no geometry")
everything 21,253 features 0.03 s
bounding box (Oakham) 5,702 features 0.01 s
expression: over 10 ha 926 features 0.03 s
one attribute, no geometry 21,253 features 0.02 s
QgsFeatureRequest is the most under-used class in the API. Narrowing what you fetch β by extent, by attribute subset, or without geometry β is usually a bigger win than any other optimisation. An expression on $area is not one of them: it has to read every geometry to evaluate, which is why it costs as much as reading everything.
Coordinate reference systems
from qgis.core import (QgsCoordinateReferenceSystem, QgsCoordinateTransform,
QgsProject, QgsGeometry, QgsPointXY)
source = QgsCoordinateReferenceSystem("EPSG:4326")
target = QgsCoordinateReferenceSystem("EPSG:27700")
print(target.description(), target.isGeographic(), target.mapUnits().name)
transform = QgsCoordinateTransform(source, target, QgsProject.instance())
point = QgsGeometry.fromPointXY(QgsPointXY(-0.7263, 52.6698)) # Oakham
point.transform(transform) # in place
print(point.asWkt(1))
to_wgs84 = QgsCoordinateTransform(vector.crs(), source, QgsProject.instance())
print(to_wgs84.transformBoundingBox(vector.extent()).toString(4))
OSGB36 / British National Grid False Meters
Point (486227 308771)
-0.8370,52.5188 : -0.4176,52.7646
The third argument to QgsCoordinateTransform is the transform context, which carries datum-transformation preferences. Passing the project is the usual choice; a script that builds its own context can pick a different transformation from the one the GUI would use.
Presentation: tree, renderers, symbols, layouts
from qgis.core import QgsSymbol, QgsRendererCategory, QgsCategorizedSymbolRenderer
from qgis.PyQt.QtGui import QColor
project = QgsProject.instance()
project.addMapLayer(layer) # registry and tree
project.addMapLayer(vector, False) # registry only
root = project.layerTreeRoot()
print("registry:", sorted(l.name() for l in project.mapLayers().values()))
print("tree: ", [node.name() for node in root.findLayers()])
group = root.insertGroup(0, "Thematic")
group.addLayer(vector)
print("tree: ", [node.name() for node in root.findLayers()])
colours = {"residential": "#0ea5e9", "farmland": "#e8f4fd", "forest": "#14b8a6"}
categories = []
for value, colour in colours.items():
symbol = QgsSymbol.defaultSymbol(layer.geometryType())
symbol.setColor(QColor(colour))
categories.append(QgsRendererCategory(value, symbol, value.title()))
layer.setRenderer(QgsCategorizedSymbolRenderer("fclass", categories))
print(type(layer.renderer()).__name__, [c.value() for c in layer.renderer().categories()])
registry: ['landuse', 'parcels']
tree: ['landuse']
tree: ['parcels', 'landuse']
QgsCategorizedSymbolRenderer ['residential', 'farmland', 'forest']
The distinction to hold onto: the registry (project.mapLayers()) is every layer the project knows about; the tree (project.layerTreeRoot()) is how they are grouped, ordered and toggled. Adding a layer to one is not the same as adding it to the other.
Processing
import processing
from qgis.core import QgsProcessingContext, QgsProcessingFeedback
context = QgsProcessingContext()
context.setProject(QgsProject.instance())
feedback = QgsProcessingFeedback()
result = processing.run("native:buffer", {
"INPUT": layer, "DISTANCE": 25, "OUTPUT": "TEMPORARY_OUTPUT",
}, context=context, feedback=feedback)
buffered_layer = result["OUTPUT"]
print(type(buffered_layer).__name__, buffered_layer.featureCount(), "features")
QgsVectorLayer 2405 features
Processing is a layer above the core API: it composes the classes above into named, parameterised operations. When an algorithm exists for what you want, use it β it is tested and identical to what the GUI does.
Guessing class names correctly
The naming is consistent enough to guess from:
Qgs the thing itself QgsFeature, QgsGeometry
QgsRegistry a global lookup QgsProviderRegistry
QgsContext settings carried into work QgsProcessingContext
QgsRequest parameters for a query QgsFeatureRequest
QgsSettings configuration QgsMapSettings
QgsUtils static helpers QgsExpressionContextUtils
QgsRenderer how a thing is drawn QgsCategorizedSymbolRenderer
QgsManager collection of things QgsLayoutManager
Together with dir() and the class documentation, that convention gets you to the right class far more often than searching.
A tour of the API in one script
The data used on this page
HM Land Registry's INSPIRE Index Polygons for Rutland (Open Government Licence, Β© Crown copyright and database rights), OpenStreetMap land use for Rutland from Geofabrik's 1 January 2026 extract (Β© OpenStreetMap contributors, ODbL), and the Copernicus DEM GLO-30 tile N52 W001 clipped to Rutland β all prepared with GDAL into the British National Grid. QGIS is then started once for the whole page.
import os
import zipfile
from pathlib import Path
import requests
from osgeo import gdal
gdal.UseExceptions()
Path("data/out").mkdir(parents=True, exist_ok=True)
if not Path("data/parcels.gpkg").exists():
reply = requests.Session().get(
"https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300)
reply.raise_for_status()
Path("data/Rutland.zip").write_bytes(reply.content)
gdal.VectorTranslate("data/parcels.gpkg", "/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
layerName="parcels", selectFields=["INSPIREID"])
if not Path("data/landuse.gpkg").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)
gdal.VectorTranslate("data/landuse.gpkg", "/vsizip/data/rutland.shp.zip/gis_osm_landuse_a_free_1.shp",
layerName="landuse", dstSRS="EPSG:27700", reproject=True)
if not Path("data/dem.tif").exists():
gdal.Warp("data/dem.tif", "/vsicurl/https://copernicus-dem-30m.s3.amazonaws.com/"
"Copernicus_DSM_COG_10_N52_00_W001_00_DEM/Copernicus_DSM_COG_10_N52_00_W001_00_DEM.tif",
dstSRS="EPSG:27700", outputBounds=(479000, 292000, 507000, 320000), xRes=30, yRes=30)
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 tour of the map in one script
A whole script, run in its own process the way a scheduled job would run it:
%%writefile tour.py
"""Everything most scripts ever need, in the order the classes relate."""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import (
QgsApplication, QgsProject, QgsVectorLayer, QgsFeatureRequest, QgsExpression,
QgsCoordinateReferenceSystem, QgsCoordinateTransform,
)
qgs = QgsApplication([], False)
qgs.initQgis()
try:
# 1. project
project = QgsProject.instance()
project.setCrs(QgsCoordinateReferenceSystem("EPSG:27700"))
# 2. layer + provider
layer = QgsVectorLayer("data/landuse.gpkg|layername=landuse", "landuse", "ogr")
if not layer.isValid():
raise SystemExit(layer.dataProvider().error().message())
project.addMapLayer(layer)
print(f"{layer.featureCount()} features, {layer.crs().authid()}, fields {layer.fields().names()}")
# 3. features + geometry, narrowed by a request
request = (QgsFeatureRequest(QgsExpression("\"fclass\" = 'residential'"))
.setSubsetOfAttributes(["fclass"], layer.fields()))
total_area = sum(feature.geometry().area() for feature in layer.getFeatures(request))
print(f"residential land use: {total_area / 10_000:,.2f} ha")
# 4. CRS transform
to_wgs84 = QgsCoordinateTransform(layer.crs(), QgsCoordinateReferenceSystem("EPSG:4326"), project)
print("extent in WGS84:", to_wgs84.transformBoundingBox(layer.extent()).toString(4))
# 5. presentation
group = project.layerTreeRoot().insertGroup(0, "Land use")
group.addLayer(layer)
# 6. processing
import processing
from processing.core.Processing import Processing
Processing.initialize()
result = processing.run("native:buffer", {
"INPUT": layer, "DISTANCE": 25, "OUTPUT": "data/out/buffered.gpkg"})
print("wrote", result["OUTPUT"])
print("project written:", project.write("data/out/tour.qgz"))
finally:
QgsProject.instance().clear()
qgs.exitQgis()
import subprocess, sys
run = subprocess.run([sys.executable, "tour.py"], capture_output=True, text=True)
print(run.stdout, "exit status", run.returncode)
2405 features, EPSG:27700, fields ['fid', 'osm_id', 'code', 'fclass', 'name']
residential land use: 1,593.99 ha
extent in WGS84: -0.8567,52.5262 : -0.3926,52.7663
wrote data/out/buffered.gpkg
project written: True
exit status 0
Example 2: introspect any class from the console
def explore(cls, filter_text: str = "") -> None:
"""List the public methods of a PyQGIS class, optionally filtered."""
names = [n for n in dir(cls)
if not n.startswith("_") and (not filter_text or filter_text.lower() in n.lower())]
for name in sorted(names):
member = getattr(cls, name)
doc = (member.__doc__ or "").strip().split("\n")[0][:70]
print(f" {name:28} {doc}")
from qgis.core import QgsGeometry, QgsVectorLayer
explore(QgsGeometry, "buffer")
explore(QgsVectorLayer, "subset")
BufferSide Side of line to buffer.
buffer buffer(self, distance: float, segments: int, feedback: QgsFeedback|Non
createWedgeBuffer createWedgeBuffer(center: QgsPoint, azimuth: float, angularWidth: floa
createWedgeBufferFromAngles createWedgeBufferFromAngles(center: QgsPoint, startAngle: float, endAn
singleSidedBuffer singleSidedBuffer(self, distance: float, segments: int, side: Qgis.Buf
taperedBuffer taperedBuffer(self, startWidth: float, endWidth: float, segments: int)
variableWidthBufferByM variableWidthBufferByM(self, segments: int) -> QgsGeometry
setSubsetString setSubsetString(self, subset: str|None) -> bool
subsetString subsetString(self) -> str
subsetStringChanged pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Se
The QGIS Python Console plus dir() is genuinely the fastest documentation there is, because it reflects your exact version.
Example 3: the class you need for a given task
TASKS = {
"open a file": "QgsVectorLayer / QgsRasterLayer with a provider key",
"read features": "layer.getFeatures(QgsFeatureRequest(...))",
"filter features": "QgsFeatureRequest + QgsExpression, or layer.setSubsetString",
"edit attributes": "layer.startEditing / changeAttributeValue, or dataProvider()",
"compute a field": "QgsExpression + QgsExpressionContext, or native:fieldcalculator",
"reproject": "QgsCoordinateTransform, or native:reprojectlayer",
"buffer / clip / dissolve":"processing.run('native:β¦')",
"style a layer": "QgsSymbol + a renderer, or layer.loadNamedStyle('x.qml')",
"group layers": "QgsProject.layerTreeRoot() β insertGroup / addLayer",
"export a map": "QgsPrintLayout + QgsLayoutExporter",
"save the project": "QgsProject.instance().write('out.qgz')",
"run an algorithm": "processing.run(id, params, context=β¦, feedback=β¦)",
}
import re
from qgis import core
for task, answer in TASKS.items():
missing = [name for name in re.findall(r"Qgs\w+", answer) if not hasattr(core, name)]
print(f"{task:26} β {answer}{' (not in qgis.core: ' + ', '.join(missing) + ')' if missing else ''}")
open a file β QgsVectorLayer / QgsRasterLayer with a provider key
read features β layer.getFeatures(QgsFeatureRequest(...))
filter features β QgsFeatureRequest + QgsExpression, or layer.setSubsetString
edit attributes β layer.startEditing / changeAttributeValue, or dataProvider()
compute a field β QgsExpression + QgsExpressionContext, or native:fieldcalculator
reproject β QgsCoordinateTransform, or native:reprojectlayer
buffer / clip / dissolve β processing.run('native:β¦')
style a layer β QgsSymbol + a renderer, or layer.loadNamedStyle('x.qml')
group layers β QgsProject.layerTreeRoot() β insertGroup / addLayer
export a map β QgsPrintLayout + QgsLayoutExporter
save the project β QgsProject.instance().write('out.qgz')
run an algorithm β processing.run(id, params, context=β¦, feedback=β¦)
Example 4: the qgis modules, and what is in each
import qgis.core, qgis.gui, qgis.analysis, qgis.PyQt.QtCore, processing
MODULES = {
"qgis.core": "data model, layers, geometry, processing, project",
"qgis.gui": "canvas, widgets, dialogs β imports fine headless, and is rarely needed there",
"qgis.analysis": "geometry analysis, network analysis, raster calculator, interpolation",
"qgis.PyQt": "the Qt bindings QGIS itself uses (QVariant, QColor, QDateβ¦)",
"processing": "the Processing plugin's Python API β needs the plugins path on sys.path",
}
for name, purpose in MODULES.items():
print(f"{name:16} {purpose}")
from qgis.gui import QgsMapCanvas
print("a QgsMapCanvas in this headless session:", QgsMapCanvas().isVisible() is False)
qgis.core data model, layers, geometry, processing, project
qgis.gui canvas, widgets, dialogs β imports fine headless, and is rarely needed there
qgis.analysis geometry analysis, network analysis, raster calculator, interpolation
qgis.PyQt the Qt bindings QGIS itself uses (QVariant, QColor, QDateβ¦)
processing the Processing plugin's Python API β needs the plugins path on sys.path
a QgsMapCanvas in this headless session: True
The rule that matters for automation is not about which module you import. It is that a headless script must tell Qt it has no display β QT_QPA_PLATFORM=offscreen β before QgsApplication is created. In a container with no display, QGIS 3.44 aborted with could not load the Qt platform plugin "xcb" at QgsApplication([], False) itself, before a single qgis.gui import; with offscreen set, the same container imported qgis.gui and created a map canvas without complaint.
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
Why PyQGIS feels un-Pythonic
PyQGIS is a SIP-generated binding over the QGIS C++ API, which is why the classes feel un-Pythonic in places: getters and setters rather than properties, Qt value types, methods returning booleans or status codes instead of raising. Once you expect that, the API is remarkably consistent.
The structural idea is containment. The application owns the registries and the CRS database. The project owns layers and their presentation. A layer owns a provider, a renderer and a field schema. A feature owns attributes and one geometry. Almost every "where do I find�" question resolves by walking that chain: extent is a property of a layer, validity is a property of a geometry, capabilities are a property of a provider.
Two objects break the containment pattern and are worth calling out. QgsProject.instance() is a process-wide singleton, so anything you add persists until you clear it β which matters in a long-running script or a plugin. And QgsApplication must outlive everything created from it, because every layer, geometry and registry entry depends on the C++ objects it owns; that is the direct cause of most PyQGIS segfaults.
Comparing the model with GeoPandas clarifies when to use which. PyQGIS is object-oriented and feature-by-feature: QgsFeature, QgsGeometry, iteration through getFeatures(). GeoPandas is table-oriented and vectorised: columns, boolean masks, whole-array operations. For bulk numerical work GeoPandas is usually shorter; for anything that must match a QGIS project β styles, expressions, layouts, Processing models, project-defined CRS behaviour β PyQGIS is the only faithful option.
Which suggests the practical division that experienced users converge on: use Processing algorithms when one exists, drop to the core classes when you need control or access to project state, and hand the data to GeoPandas when the work is really a table computation. The three interoperate easily, and knowing the map above is what makes moving between them cheap.
Singletons, status codes and QVariant
QgsProject.instance()is a singleton: Callclear()between builds in the same process.- One
QgsApplicationper process: Keep it alive until the end;exitQgis()followed by a new application in the same process crashes. - Methods return booleans or status codes, not exceptions:
addFeature,commitChanges,changeAttributeValuereport failure by returningFalse;translateandtransformreturn a status code. QVariantleaks through: Older versions returnQVariant()for NULL; test with.isNull()or compare againstNonecarefully.- Set
QT_QPA_PLATFORM=offscreenheadless: Without a display, Qt aborts whenQgsApplicationis created, whatever you import. - Some geometry methods mutate:
transformandtranslatechange in place;bufferandmakeValidreturn new objects. QgsFeature.id()is provider-assigned: It is a handle, not a row number, and not necessarily your primary key.
Internal links
- How to Automate QGIS with Python (PyQGIS): The Complete Workflow
- PyQGIS vs GeoPandas: Which to Use for GIS Automation
- QGIS Data Providers Explained: How QGIS Reads Any Data Source
- The QGIS Processing Framework Explained
- How to Add, Edit and Delete Features with PyQGIS
- How to Run Field Calculator Expressions from PyQGIS
- How to Build and Save a QGIS Project File from Python
FAQ
Which PyQGIS classes do I actually need?
About fifteen: QgsApplication, QgsProject, QgsVectorLayer, QgsRasterLayer, QgsFeature, QgsGeometry, QgsPointXY, QgsField(s), QgsFeatureRequest, QgsExpression, QgsCoordinateReferenceSystem, QgsCoordinateTransform, plus the Processing context and feedback.
What is the difference between a layer and its provider?
The layer owns rendering, styling, selection and the edit buffer; the provider owns the connection to the data source and reports what operations it supports.
When should I use Processing instead of the core classes?
Whenever an algorithm exists for the operation. It is faster, tested, and identical to what the GUI does β and it composes into models and the command line.
How do I explore the API without leaving QGIS?
Use the Python Console with dir(QgsGeometry) and help(...). It reflects your installed version, which the online documentation may not.
Why does my script crash at exit?
Almost always a lifetime problem: QgsApplication was destroyed while layers were still alive, a Python name still pointed at a layer the project had deleted, or exitQgis() was never called. Clear the project, then exit the application, once, inside a finally.
Can I mix PyQGIS and GeoPandas?
Yes, and it is often the best of both. Move data via GeoPackage or WKB, do table-heavy work in GeoPandas, and use PyQGIS for anything that must match the project.
Which modules are safe in a headless script?
All of them, as long as QT_QPA_PLATFORM=offscreen is set before QgsApplication is created. In a display-less container QGIS 3.44 aborted at QgsApplication([], False) without it, and with it imported qgis.gui and created a map canvas. Most headless scripts still need only qgis.core, qgis.analysis and processing.