The PyQGIS API Map: Which Class Does What
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.
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/parcels.gpkg|layername=parcels", "parcels", "ogr")
QgsProject.instance().addMapLayer(layer)
for feature in layer.getFeatures(QgsFeatureRequest(QgsExpression('"area_m2" > 500'))):
geometry = feature.geometry()
print(feature["parcel_id"], geometry.area(), geometry.centroid().asPoint())
Five lines that touch five of the six groups β which is roughly the shape of most PyQGIS scripts.
The containment map
Step-by-step solution
Application and project
from qgis.core import QgsApplication, QgsProject
QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False) # False = no GUI
qgs.initQgis()
project = QgsProject.instance() # a singleton β one per process
project.setCrs(QgsCoordinateReferenceSystem("EPSG:27700"))
project.read("atlas.qgz") # or build one from scratch
print(project.title(), len(project.mapLayers()), "layers")
project.write("out/atlas.qgz")
project.clear()
qgs.exitQgis()
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(), vector.wkbType())
provider = vector.dataProvider() # the connection to the source
print(provider.name(), provider.capabilities())
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, QgsField
from qgis.PyQt.QtCore import QVariant
feature = QgsFeature(layer.fields()) # correct field order, guaranteed
feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(325000, 674000)))
feature["name"] = "New site"
print(feature.id()) # provider-assigned handle
print(feature.attributes()) # values, in field order
print(feature.fields().names()) # the schema
print(feature.geometry().asWkt()[:60])
The geometry API is where most of the useful methods live:
geometry = feature.geometry()
geometry.area(); geometry.length(); geometry.centroid(); geometry.boundingBox()
geometry.buffer(25, 8); geometry.simplify(1.0); geometry.convexHull()
geometry.intersects(other); geometry.within(other); geometry.distance(other)
geometry.intersection(other); geometry.difference(other); geometry.combine(other)
geometry.isGeosValid(); geometry.makeValid()
geometry.asWkt(); geometry.asJson(); QgsGeometry.fromWkt(wkt)
Note the two families of methods: some mutate in place (transform, translate), others return a new geometry (buffer, simplify, makeValid). Mixing them up produces code that appears to do nothing.
Requesting features efficiently
from qgis.core import QgsFeatureRequest, QgsExpression, QgsRectangle
# everything
for feature in layer.getFeatures():
...
# by expression β the same language as the Field Calculator
request = QgsFeatureRequest(QgsExpression('"class" = \'residential\' AND "area_m2" > 500'))
# by bounding box β uses the provider's spatial index
request = QgsFeatureRequest(QgsRectangle(325000, 673000, 335000, 683000))
# read less: only some attributes, and skip geometry entirely
request = (QgsFeatureRequest()
.setSubsetOfAttributes(["parcel_id", "class"], layer.fields())
.setFlags(QgsFeatureRequest.NoGeometry)
.setLimit(1000))
for feature in layer.getFeatures(request):
print(feature["parcel_id"])
QgsFeatureRequest is the most under-used class in the API. Narrowing what you fetch β by expression, by extent, by attribute subset, or without geometry β is usually a bigger win than any other optimisation.
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())
transform = QgsCoordinateTransform(source, target, QgsProject.instance())
point = QgsGeometry.fromPointXY(QgsPointXY(-3.188, 55.953))
point.transform(transform) # in place
print(point.asWkt(1))
extent = transform.transformBoundingBox(layer.extent())
The third argument to QgsCoordinateTransform is the transform context, which carries datum-transformation preferences. Passing the project is the usual choice; omitting it entirely is a common source of small positional differences between the GUI and a script.
Presentation: tree, renderers, symbols, layouts
from qgis.core import QgsProject, QgsSymbol, QgsRendererCategory, QgsCategorizedSymbolRenderer
from qgis.PyQt.QtGui import QColor
root = QgsProject.instance().layerTreeRoot() # groups, order, visibility
group = root.insertGroup(0, "Thematic")
group.addLayer(layer)
root.findLayer(layer.id()).setItemVisibilityChecked(True)
symbol = QgsSymbol.defaultSymbol(layer.geometryType())
symbol.setColor(QColor("#0ea5e9"))
layer.renderer().setSymbol(symbol)
categories = [QgsRendererCategory("residential", symbol.clone(), "Residential")]
layer.setRenderer(QgsCategorizedSymbolRenderer("class", categories))
layer.triggerRepaint()
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()
feedback = QgsProcessingFeedback()
result = processing.run("native:buffer", {
"INPUT": layer, "DISTANCE": 25, "OUTPUT": "TEMPORARY_OUTPUT",
}, context=context, feedback=feedback)
buffered = context.getMapLayer(result["OUTPUT"])
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 faster, 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.
Code examples
Example 1: a tour of the map in one script
"""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, QgsFeature, QgsGeometry,
QgsPointXY, QgsFeatureRequest, QgsExpression,
QgsCoordinateReferenceSystem, QgsCoordinateTransform,
)
QgsApplication.setPrefixPath("/usr", True)
qgs = QgsApplication([], False)
qgs.initQgis()
try:
# 1. project
project = QgsProject.instance()
project.setCrs(QgsCoordinateReferenceSystem("EPSG:27700"))
# 2. layer + provider
layer = QgsVectorLayer("data/parcels.gpkg|layername=parcels", "parcels", "ogr")
if not layer.isValid():
raise SystemExit(layer.dataProvider().error().message())
project.addMapLayer(layer)
print(f"{layer.featureCount()} features, {layer.crs().authid()}, "
f"fields {layer.fields().names()}")
# 3. features + geometry, narrowed by a request
request = (QgsFeatureRequest(QgsExpression('"class" = \'residential\''))
.setSubsetOfAttributes(["parcel_id", "class"], layer.fields()))
total_area = 0.0
for feature in layer.getFeatures(request):
total_area += feature.geometry().area()
print(f"residential area: {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
root = project.layerTreeRoot()
group = root.insertGroup(0, "Parcels")
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"])
project.write("data/out/tour.qgz")
finally:
QgsProject.instance().clear()
qgs.exitQgis()
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:34} {doc}")
from qgis.core import QgsGeometry, QgsVectorLayer
explore(QgsGeometry, "buffer")
explore(QgsVectorLayer, "field")
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=β¦)",
}
for task, answer in TASKS.items():
print(f"{task:26} β {answer}")
Example 4: the qgis modules, and what is in each
import qgis.core, qgis.gui, qgis.analysis, qgis.PyQt.QtCore
MODULES = {
"qgis.core": "data model, layers, geometry, processing, project β headless-safe",
"qgis.gui": "canvas, widgets, dialogs β needs a GUI, do not import headless",
"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}")
The rule that matters for automation: qgis.core is safe headless, qgis.gui is not. Importing qgis.gui in a server script is a common cause of an unexplained crash.
Explanation
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, QVariant values, methods returning booleans 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 faster and 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.
Edge cases or notes
QgsProject.instance()is a singleton: Callclear()between builds in the same process.- Keep
QgsApplicationalive: Let it go out of scope and everything derived from it becomes invalid β usually a segfault at exit. - Methods return booleans, not exceptions:
addFeature,commitChanges,changeAttributeValueall report failure by returningFalse. QVariantleaks through: Older versions returnQVariant()for NULL; test with.isNull()or compare againstNonecarefully.- Do not import
qgis.guiheadless: It pulls in widgets that need a display. - 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 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, or exitQgis() was never called. Clear the project, then exit the application, 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?
qgis.core, qgis.analysis and processing. Avoid qgis.gui β it requires a display and is the cause of many unexplained server-side crashes.