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:

  1. Application β€” QgsApplication starts everything; QgsProject holds the document
  2. Layers β€” QgsVectorLayer, QgsRasterLayer, and the QgsDataProvider underneath each
  3. Data β€” QgsFeature, QgsGeometry, QgsFields, QgsFeatureRequest
  4. Reference systems β€” QgsCoordinateReferenceSystem, QgsCoordinateTransform
  5. Presentation β€” the layer tree, renderers, symbols, layouts
  6. 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 PyQGIS containment chain from application to geometry. each level owns the nextQgsApplicationthe runtime, registriesQgsProjectlayers, tree, layoutsQgsVectorLayerrender, style, providerQgsFeatureid + attributesQgsGeometrythe shape itselfMost β€œwhere do I find…?” questions resolve by walking this chain spatialworkflow.io
Each level owns the next β€” knowing the chain tells you where to look for anything.

The classes, from application to Processing

The six groups of PyQGIS classes and what you use from each. fifteen classes cover most workthe classesforapplicationQgsApplication, QgsProjectruntime + documentlayersQgsVectorLayer, providersourcesdataQgsFeature, QgsGeometrythe data itselfreference systemsQgsCoordinateTransformreprojectionpresentationlayer tree, renderershow it looksprocessingβœ“processing.run, contextnamed operationsNaming is consistent enough to guess the rest spatialworkflow.io
Six groups, fifteen classes β€” enough for the great majority of automation.

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.

Four QgsGeometry methods and whether they return a new geometry or change the one they are called on. returns a new geometry, or changes this one?methodreturnsthe geometry it was called onbuffer(25, 8)a new QgsGeometryunchangedmakeValid()a new QgsGeometryunchangedtranslate(100, 0)0 (a status code)moved in placetransform(ct)a status codereprojected in placeA bare geometry.buffer(25) line computes a buffer and throws it away spatialworkflow.io
buffer and translate were run above on Rutland's largest land-use polygon, 1,140 ha of farmland, under QGIS 4.2; makeValid and transform as the API documents them.

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 PyQGIS object model compared with the GeoPandas table model. two mental modelsPyQGISβœ“objects: layer, feature, geometryβœ“iterate with getFeatures()βœ“faithful to the QGIS projectβœ“styles, expressions, layoutsGeoPandasβœ“a table with a geometry columnβœ“vectorised, whole-columnβœ“faster for bulk mathsβœ“runs anywhere, no QGISUse PyQGIS when the result must match a QGIS project spatialworkflow.io
Two mental models for the same data β€” feature-by-feature objects versus a vectorised table.

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: Call clear() between builds in the same process.
  • One QgsApplication per 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, changeAttributeValue report failure by returning False; translate and transform return a status code.
  • QVariant leaks through: Older versions return QVariant() for NULL; test with .isNull() or compare against None carefully.
  • Set QT_QPA_PLATFORM=offscreen headless: Without a display, Qt aborts when QgsApplication is created, whatever you import.
  • Some geometry methods mutate: transform and translate change in place; buffer and makeValid return new objects.
  • QgsFeature.id() is provider-assigned: It is a handle, not a row number, and not necessarily your primary key.

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.