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.
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.
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):
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:
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:
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:
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:
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.
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(), andsetFilterRect(). Shapefiles still read the.dbfheader 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 checkQgsVectorDataProvider.SelectSubsetOfAttributesbefore 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:
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 aQgsFeatureRequest, even if the only constraint issetNoGeometry(). - 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
getFeaturesis 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
QgsFeatureobjects 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 olderQgsFeatureRequest.NoGeometryflag, which was deprecated in 3.12). - Wrap provider-dependent logic in capability checks and log a
Qgis.Warningwhen a fallback path is taken, so users understand why iteration is slower than expected.
Related
- Vector and Raster Data Access Patterns — parent guide covering the full range of layer read and write patterns in PyQGIS
- Spatial Indexing and Query Optimization — how to build and query
QgsSpatialIndexfor proximity lookups - Memory Management and Garbage Collection for GIS Objects — SIP ownership rules and preventing memory leaks when iterating large raster or vector datasets
- Coordinate Transformations and CRS Handling — how
QgsCoordinateTransforminteracts with geometry loading during iteration