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:

  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/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

Layered map from QgsApplication through project, layers, features to geometry.
Each level owns the next β€” knowing the chain tells you where to look for anything.

Step-by-step solution

Grid of the six PyQGIS class groups with the classes you actually use in each.
Six groups, fifteen classes β€” enough for the great majority of automation.

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.

Panels contrasting the PyQGIS object model with the GeoPandas table model.
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 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: Call clear() between builds in the same process.
  • Keep QgsApplication alive: 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, changeAttributeValue all report failure by returning False.
  • QVariant leaks through: Older versions return QVariant() for NULL; test with .isNull() or compare against None carefully.
  • Do not import qgis.gui headless: It pulls in widgets that need a display.
  • 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, 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.