Optimizing Feature Iteration with QgsVectorLayer.getFeatures

How to use QgsFeatureRequest to constrain geometry loading, attribute fetching, and spatial filters so that QgsVectorLayer.getFeatures scales from thousands…

TL;DR: Always pass a QgsFeatureRequest to layer.getFeatures(). Disable geometry loading with setNoGeometry(), restrict columns with setSubsetOfAttributes(), and push spatial or expression filters to the provider before iteration — this keeps memory constant and avoids transferring data that your code never uses.

This page is part of the Vector and Raster Data Access Patterns guide, which is itself a section of PyQGIS Core Architecture & Data Handling.

Complete Runnable Template

Drop this into any QGIS 3.10+ plugin or standalone script. No placeholders — substitute your layer name, field name, and optional bounding rectangle.

python
from qgis.core import (
    QgsVectorLayer,
    QgsFeatureRequest,
    QgsRectangle,
    QgsMessageLog,
    Qgis,
)


def stream_field_values(
    layer: QgsVectorLayer,
    target_field: str,
    search_rect: QgsRectangle | None = None,
) -> object:
    """Yield attribute values for one field without loading geometries or
    unused columns into RAM.

    Compatible with QGIS 3.10+ (QgsFeatureRequest.setNoGeometry stable).
    Raises ValueError for invalid layers and KeyError for missing fields.
    """
    if not layer.isValid():
        raise ValueError(f"Layer '{layer.name()}' is invalid or not loaded.")

    # Resolve field index — handles aliases and case-insensitive names
    field_idx = layer.fields().lookupField(target_field)
    if field_idx == -1:
        raise KeyError(f"Field '{target_field}' not found in layer '{layer.name()}'.")

    # Build the constrained request
    request = QgsFeatureRequest()
    request.setNoGeometry()               # skip WKB decode + CRS transform
    request.setSubsetOfAttributes([field_idx])  # project only needed column

    if search_rect and not search_rect.isNull():
        request.setFilterRect(search_rect)  # push bbox to provider

    QgsMessageLog.logMessage(
        f"Iterating '{layer.name()}' field='{target_field}' "
        f"rect={bool(search_rect)}",
        "stream_field_values",
        Qgis.Info,
    )

    # Stream one feature at a time — constant memory regardless of dataset size
    for feat in layer.getFeatures(request):
        yield feat.attribute(field_idx)


# --- Usage -----------------------------------------------------------------
# layer = QgsProject.instance().mapLayersByName("parcels")[0]
# rect  = QgsRectangle(10.0, 45.0, 12.0, 47.0)
# for code in stream_field_values(layer, "land_use_code", rect):
#     process(code)

Request Pipeline — How the C++ Layer Sees Each Call

The diagram below shows how QgsFeatureRequest constraints move through the stack before a single Python QgsFeature object is created. Every constraint applied here eliminates work in C++, not in Python — which is why it matters.

QgsFeatureRequest Constraint Pipeline Diagram showing how QgsFeatureRequest constraints (setNoGeometry, setSubsetOfAttributes, setFilterRect, setFilterExpression) flow from Python through the QGIS C++ core to the data provider, and how each constraint reduces the data returned before it crosses the C++/Python boundary. Python QgsFeatureRequest() .setNoGeometry() .setSubsetOfAttributes() SIP boundary C++ QgsVectorLayer compiles request → native provider query setFilterRect() bbox → WHERE ST_Intersects / BBOX setFilterExpression() compiled to SQL / OGC Filter / in-memory Data Provider GeoPackage · PostGIS · Shapefile · WFS · Memory filtered rows only QgsFeature (one at a time, generator)

Architecture Breakdown

QgsFeatureRequest — the gatekeeper

QgsFeatureRequest is a value object: it carries a set of constraints that QgsVectorLayer hands directly to the data provider before any feature object crosses the C++/Python boundary. It is not a query object you run separately — you attach it to getFeatures() and the provider decides how to honour it.

Key contract: once you call layer.getFeatures(request), the returned iterator is tied to that layer’s current transaction. Do not modify the layer (add features, change attributes) while iterating; the behaviour is undefined.

setNoGeometry() — skip WKB decoding

Geometry parsing is the most expensive per-feature operation in QgsVectorLayer iteration. Even when you only need tabular data, QGIS will by default decode WKB strings, apply coordinate transformations if a destination CRS is set, and instantiate a QgsGeometry object backed by a GEOS structure. Calling request.setNoGeometry() instructs the provider to bypass all of this. For attribute-heavy workflows — CSV exports, statistical summaries, database joins — this alone can cut per-feature cost by 40–70%.

Gotcha: if you call setNoGeometry() and then call feat.geometry() in your loop, you receive a null geometry. This is correct behaviour, not a bug. Guard with feat.hasGeometry() if the downstream code is shared.

setSubsetOfAttributes() — column projection

Fetching all columns forces the provider to read every field’s disk page or network payload. request.setSubsetOfAttributes([idx]) pushes a projection down to the data source. For wide PostGIS tables (50+ columns) or WFS services where response size is billed, this is critical. Always resolve the field index with layer.fields().lookupField(name) rather than hard-coding integers — field order can differ across providers and QGIS versions.

Pass an empty list [] when you need no attributes at all (e.g., when building a spatial index):

python
index_request = QgsFeatureRequest().setNoAttributes()
# equivalent to: QgsFeatureRequest().setSubsetOfAttributes([])

setFilterRect() — bounding-box push-down

When you provide a QgsRectangle, QGIS compiles it to the provider’s native spatial filter: ST_Intersects for PostGIS, BBOX for WFS, an R-tree pre-filter for GeoPackage and Shapefile. Features whose envelopes do not intersect the rectangle never leave the provider. This is a coarse filter — exact geometry intersection must be verified in Python if needed:

python
from qgis.core import QgsGeometry

query_geom = QgsGeometry.fromWkt("POLYGON((10 45, 12 45, 12 47, 10 47, 10 45))")
for feat in layer.getFeatures(QgsFeatureRequest().setFilterRect(query_geom.boundingBox())):
    if feat.geometry().intersects(query_geom):   # exact check
        process(feat)

setFilterExpression() — SQL-like push-down

setFilterExpression("population > 50000 AND country = 'DE'") is compiled to the provider’s native query language — SQLite WHERE clause for GeoPackage, full SQL for PostGIS, OGC Filter XML for WFS. The expression uses QGIS’s own expression engine, so field aliases and virtual fields are resolved before the query reaches the provider. Verify provider support at runtime:

python
from qgis.core import QgsVectorDataProvider

caps = layer.dataProvider().capabilities()
if caps & QgsVectorDataProvider.FilterFeatures:
    request.setFilterExpression("status = 'active'")

setFilterFids() — fetch by known IDs

After a spatial index lookup returns a list of feature IDs, use setFilterFids(ids) to fetch only those rows. This avoids re-scanning the full layer and is where is the number of matched IDs, not over the full dataset.

Integrating QgsSpatialIndex for Proximity Queries

For nearest-neighbour searches and radius queries, setFilterRect() alone performs a bounding-box pre-filter but still returns all features in the envelope. Build a QgsSpatialIndex once per session, then use it to narrow the ID list before calling getFeatures:

python
from qgis.core import (
    QgsSpatialIndex,
    QgsFeatureRequest,
    QgsPointXY,
    QgsVectorLayer,
)


def build_spatial_index(layer: QgsVectorLayer) -> QgsSpatialIndex:
    """Build an in-memory R-tree index over all features in the layer.

    Call once and reuse. Index is O(n) to build, O(log n) per query.
    See: /pyqgis-core-architecture-data-handling/spatial-indexing-and-query-optimization/
    """
    return QgsSpatialIndex(
        layer.getFeatures(QgsFeatureRequest().setNoAttributes())
    )


def nearest_features(
    layer: QgsVectorLayer,
    index: QgsSpatialIndex,
    point: QgsPointXY,
    k: int = 5,
) -> list:
    """Return up to k features nearest to point, using index for O(log n) lookup."""
    ids = index.nearestNeighbor(point, k)
    request = QgsFeatureRequest().setFilterFids(ids)
    return list(layer.getFeatures(request))

The spatial index lives in memory and uses an R-tree structure. Proximity lookups cost instead of . Rebuild the index only when the layer is edited — connect to layer.featuresAdded and layer.featuresDeleted signals from the signal and slot event system to invalidate a cached index.

Wiring Into a Plugin or Standalone Script

Plugin context

In a QGIS plugin, layers are managed by QgsProject; retrieve them by name or ID before building a request. Always check validity before iterating — a layer can become invalid if the underlying file moves or the database connection drops:

python
from qgis.core import QgsProject, QgsFeatureRequest, QgsMessageLog, Qgis


def export_active_parcels(field_name: str = "parcel_id") -> list[str]:
    """Plugin entry point: export active parcel IDs from the loaded layer.

    Compatible with QGIS 3.16+. Logs errors to the QGIS message panel.
    """
    layers = QgsProject.instance().mapLayersByName("parcels")
    if not layers:
        QgsMessageLog.logMessage("Layer 'parcels' not found.", "MyPlugin", Qgis.Warning)
        return []

    layer = layers[0]
    if not layer.isValid():
        QgsMessageLog.logMessage("Layer 'parcels' invalid.", "MyPlugin", Qgis.Critical)
        return []

    field_idx = layer.fields().lookupField(field_name)
    if field_idx == -1:
        return []

    request = (
        QgsFeatureRequest()
        .setNoGeometry()
        .setSubsetOfAttributes([field_idx])
        .setFilterExpression("status = 'active'")
    )
    return [feat.attribute(field_idx) for feat in layer.getFeatures(request)]

Standalone script

In a headless script (e.g., batch processing or CI pipeline), initialise the QGIS application before loading layers. Memory management differs slightly: QgsApplication must remain alive for the duration of all layer operations. This is covered in detail in the working with QgsProject and the layer registry guide.

python
import sys
from qgis.core import (
    QgsApplication,
    QgsVectorLayer,
    QgsFeatureRequest,
)

app = QgsApplication([], False)
QgsApplication.setPrefixPath("/usr", True)
app.initQgis()

layer = QgsVectorLayer("/data/parcels.gpkg|layername=parcels", "parcels", "ogr")
field_idx = layer.fields().lookupField("land_use_code")

request = QgsFeatureRequest().setNoGeometry().setSubsetOfAttributes([field_idx])
for feat in layer.getFeatures(request):
    print(feat.attribute(field_idx))

app.exitQgis()

Provider Capabilities and Edge Cases

Not every provider honours all constraints. Falling back silently to full-table scans is a common source of unexpected memory spikes in production plugins.

  • GeoPackage and Shapefile: fully respect setNoGeometry(), setSubsetOfAttributes(), and setFilterRect(). Shapefiles still read the .dbf header row, but column projection is applied before Python objects are created.
  • PostGIS and SpatiaLite: push attribute projections and filters directly to SQL. Performance gains here are maximal — the database does the heavy lifting.
  • WFS and remote OGC services: may ignore setSubsetOfAttributes() depending on server configuration. Some endpoints return full feature collections regardless of client-side constraints. Always check QgsVectorDataProvider.SelectSubsetOfAttributes before relying on it.
  • Virtual layers and memory layers: constraints are applied in-memory. Fast for small datasets, but without disk-level I/O skipping — avoid materialising large memory layers when a GeoPackage would serve the same purpose.
  • CSV and delimited text: no spatial index; setFilterRect() degrades to a Python-side envelope scan. Use GeoPackage or PostGIS for spatial queries on tabular data.

Always validate capability flags before committing to a pattern in production:

python
from qgis.core import QgsVectorDataProvider

provider = layer.dataProvider()
caps = provider.capabilities()

supports_filter   = bool(caps & QgsVectorDataProvider.FilterFeatures)
supports_subset   = bool(caps & QgsVectorDataProvider.SelectSubsetOfAttributes)

if not supports_subset:
    QgsMessageLog.logMessage(
        f"Provider '{provider.name()}' does not support attribute subsetting; "
        "all columns will be fetched.",
        "FeatureIteration",
        Qgis.Warning,
    )

Production Best Practices

  • Never call bare layer.getFeatures() — always construct a QgsFeatureRequest, even if the only constraint is setNoGeometry().
  • Resolve field indices with layer.fields().lookupField(name) at setup time; cache the result rather than calling it inside the loop.
  • Use generators (for feat in layer.getFeatures(request)) for large datasets; materialise to a list only when the downstream API requires one and the result set is known to be small.
  • Do not mutate the layer (add, delete, or modify features) while an iterator from getFeatures is alive — the provider cursor state is undefined after structural edits.
  • For repeated proximity queries, build the spatial index once and pass it between calls rather than rebuilding per query.
  • In plugins that handle memory management for GIS objects, avoid storing large lists of QgsFeature objects across function boundaries — the SIP ownership rules mean Python may not control when the underlying C++ object is freed.
  • When targeting QGIS 3.10 through 3.36, use QgsFeatureRequest().setNoGeometry() (not the older QgsFeatureRequest.NoGeometry flag, which was deprecated in 3.12).
  • Wrap provider-dependent logic in capability checks and log a Qgis.Warning when a fallback path is taken, so users understand why iteration is slower than expected.