Speed Up Feature Iteration with QgsFeatureRequest
for feature in layer.getFeatures() is the first line of PyQGIS everyone learns and the last one they think to optimise. It is also, on a wide table, the most expensive thing in the script: every column is read, every geometry is decoded, and the loop body then uses two fields and ignores the shape entirely.
QgsFeatureRequest is how you say what you actually need. It is the cheapest performance win in PyQGIS — usually a two-line change — and it frequently removes the need for the background task you were about to write.
This recipe belongs to Background Tasks and Plugin Performance. It covers attribute subsets, dropping geometry, expression and rectangle filters, sorting and limiting, and the traps that make a request silently return the wrong thing.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- A layer with enough rows that timing means something — a few tens of thousands upward.
time.perf_counter()and a willingness to measure before and after; the numbers vary hugely by provider and table shape.
Ask for fewer attributes
from qgis.core import QgsFeatureRequest
request = QgsFeatureRequest().setSubsetOfAttributes(
["parcel_ref", "area_m2"], layer.fields()
)
for feature in layer.getFeatures(request):
process(feature["parcel_ref"], feature["area_m2"])
Breakdown: The two-argument form takes field names and the layer's field list, which is far less error-prone than the index-based overload — a hard-coded index breaks the moment someone adds a column. The provider then reads only those columns; on a PostGIS layer the narrowed column list even reaches the SELECT. Accessing an attribute that was not requested returns NULL rather than raising, which is exactly how this optimisation turns into a silent bug, so keep the requested list and the loop body next to each other.
Drop geometry when you do not need it
request = QgsFeatureRequest().setFlags(QgsFeatureRequest.NoGeometry)
total = sum(feature["area_m2"] for feature in layer.getFeatures(request))
Breakdown: NoGeometry skips both the read and the decode of the geometry blob. For polygon layers with detailed boundaries this is often the single largest saving available, because parsing thousands of vertices per feature dwarfs reading a handful of numbers. The features still arrive; feature.geometry() simply returns an empty geometry, so any code path that touches the shape must be excluded — a good reason to keep attribute-only passes in their own function.
Filter in the provider, not in Python
Every if at the top of a loop body is a row the provider should not have sent.
from qgis.core import QgsFeatureRequest, QgsRectangle
by_expression = QgsFeatureRequest().setFilterExpression(
'"status" = \'active\' AND "area_m2" > 500'
)
in_view = QgsFeatureRequest().setFilterRect(
QgsRectangle(432000, 187000, 436000, 191000)
)
by_ids = QgsFeatureRequest().setFilterFids([12, 48, 91])
Breakdown: setFilterExpression() accepts any QGIS expression, and where the provider can translate it — PostGIS can translate most — it becomes a WHERE clause evaluated by the database. setFilterRect() uses the layer's spatial index, so it is orders of magnitude faster than testing each geometry's bounding box in Python; note that it selects features whose bounding box intersects, so an exact test is still needed for precise work. setFilterFids() is the right way to re-read a known set of features, and it is what a selection-driven plugin should use rather than iterating everything and comparing ids. Expression syntax is covered in Select Features by Expression in PyQGIS.
Combine, sort and limit
Requests chain, because each setter returns the request.
request = (
QgsFeatureRequest()
.setFilterExpression('"status" = \'active\'')
.setSubsetOfAttributes(["parcel_ref", "area_m2"], layer.fields())
.setFlags(QgsFeatureRequest.NoGeometry)
.addOrderBy("area_m2", ascending=False)
.setLimit(20)
)
largest = list(layer.getFeatures(request))
Breakdown: This is the "twenty largest active parcels" question answered in one provider round trip instead of a full read plus a Python sort. addOrderBy() is translated to ORDER BY where the provider supports it and falls back to an in-QGIS sort where it does not, so it is always correct and sometimes fast. setLimit() caps the number returned — combined with an ordering it is a top-N query, and used alone it is the right way to sample a layer while developing. Note that setFlags() replaces the flag set rather than adding to it, so call it once with everything you want.
Measure the difference
import time
from qgis.core import QgsFeatureRequest
def timed(label, request=None):
started = time.perf_counter()
count = sum(1 for _ in (layer.getFeatures(request) if request else layer.getFeatures()))
print(f"{label}: {count} features in {time.perf_counter() - started:.2f}s")
timed("everything")
timed("narrowed", QgsFeatureRequest()
.setSubsetOfAttributes(["area_m2"], layer.fields())
.setFlags(QgsFeatureRequest.NoGeometry))
Breakdown: perf_counter() is monotonic and high-resolution, which matters for durations under a second. Counting with sum(1 for _ in …) avoids building a list, so the measurement reflects iteration rather than memory allocation. Run each twice and take the second number — the first pass warms the operating system's file cache, and comparing a cold read against a warm one produces a flattering result that will not survive production. The wider profiling story is in Profile Slow PyQGIS Code.
Comparing two layers without a nested loop
A feature request narrows one layer's read. It cannot help with the other classic slow pattern: comparing every feature of one layer against every feature of another, which costs the product of the two counts and becomes unusable well before either layer is large.
from qgis.core import QgsSpatialIndex, QgsFeatureRequest
index = QgsSpatialIndex(wards.getFeatures(QgsFeatureRequest()))
ward_geometries = {f.id(): f.geometry() for f in wards.getFeatures()}
request = QgsFeatureRequest().setSubsetOfAttributes(["id"], incidents.fields())
for incident in incidents.getFeatures(request):
geometry = incident.geometry()
for candidate_id in index.intersects(geometry.boundingBox()):
if ward_geometries[candidate_id].contains(geometry):
assign(incident, candidate_id)
break
Breakdown: The index answers "which wards could possibly contain this point" from a bounding-box tree, turning the inner loop from every ward into typically one or two candidates. The exact contains() test still runs, because a bounding box overlap is not containment — skipping that second test is the most common way this optimisation produces wrong answers. Caching the geometries in a dictionary avoids re-reading each candidate from the provider inside the loop, which would reintroduce the cost the index just removed. break stops at the first containing ward, which is correct when the wards tile the area without overlapping.
The cost model is worth internalising: the nested loop is proportional to n × m, the indexed version to roughly n log m. On ten thousand points and five hundred wards that is five million comparisons against about ninety thousand — the difference between a coffee break and a blink. When both layers live in the same database, the equivalent query with a GiST index is faster still and needs no Python at all, as shown in Spatial Join Points to Polygons in PyQGIS.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | All methods shown are present and behave identically. |
| 3.28 LTR | 3.9 | Adds setFilterExpression support for more provider push-down cases. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Flag members are scoped (QgsFeatureRequest.Flag.NoGeometry); the unscoped spelling still resolves. |
Troubleshooting
- Attributes are suddenly
NULL. They were excluded bysetSubsetOfAttributes(). Requesting a narrower set silently nulls the rest rather than raising. feature.geometry()is empty.NoGeometryis set. Remove the flag for that pass, or split the work into two passes.- The rectangle filter returns features outside the rectangle. It filters on bounding boxes. Follow with an exact predicate — see Check Whether Geometries Intersect in PyQGIS.
- The expression filter is slow on PostGIS. It could not be translated, so QGIS is evaluating it locally on every row. Simplify the expression, or move the condition into a subset string or query layer.
- Ordering appears to be ignored. Some providers cannot sort; QGIS then sorts locally, which needs the whole result in memory and is unaffected by
setLimit(). - A second
setFlags()call lost an earlier flag. Flags replace rather than accumulate. Pass them together, combined with|.
Conclusion
Before threading a slow loop, narrow the request: name the attributes you use, add NoGeometry when you do not touch the shape, and push every filter — spatial, attribute or identifier — down to the provider. Measure both versions on warm caches. Most loops get several times faster for two lines of change, which is a better outcome than making the same slow loop asynchronous.
Frequently Asked Questions
Does a feature request work on any layer type? Yes. Every vector provider accepts one; what differs is how much of it can be pushed down. A memory layer honours everything in QGIS, a PostGIS layer translates most of it into SQL.
Is setSubsetOfAttributes worth it for a narrow table?
Rarely. The saving scales with the number and width of the columns you skip — on a five-column table it is noise, on a forty-column one it is substantial.
Can I reuse one request object for several iterations? Yes, a request is a plain value object and can be reused or copied. Build it once outside the loop rather than per iteration.
How does this interact with a layer's subset string? They compose: the subset string restricts the layer itself, and the request restricts that. Both end up in the provider's query where possible.
Should I still use a spatial index?setFilterRect() uses the provider's index. QgsSpatialIndex is for comparisons between two layers in memory, which the request cannot help with — see Build a Spatial Index in PyQGIS.