Read Feature Attributes and Geometry in PyQGIS
Almost every PyQGIS script reads features. It is the first thing people learn and the place where the most subtle bugs hide: a list of features whose geometries all turn out identical, attribute values that compare unequal to None even though they are empty, a loop over a large layer that takes minutes because it fetched forty columns to use two. None of these are hard to avoid once you know what a feature iterator actually returns.
This recipe belongs to Features, Geometries & Memory Layers. It covers iterating a layer, reading attributes in each of the useful ways, reading geometry and its parts, fetching single features and selections, and limiting what the provider reads.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- A loaded vector layer. The examples use a polygon layer called
districtswith fieldscode,name,populationandarea_km2.
Iterate a layer and read attributes
layer.getFeatures() returns an iterator. Each item is a QgsFeature, and attributes can be read by field name, by position, or all at once.
from qgis.core import QgsProject
districts = QgsProject.instance().mapLayersByName("districts")[0]
fields = districts.fields()
pop_idx = fields.indexOf("population")
for f in districts.getFeatures():
code = f["code"] # by name
pop = f.attribute(pop_idx) # by index: faster in tight loops
row = dict(zip(fields.names(), f.attributes())) # whole row as a dict
print(f.id(), code, pop, row["name"])
Breakdown: f["code"] looks the name up in the feature's fields every call, which is fine for readability and costs a little in loops over millions of features — resolving the index once with indexOf and using attribute(idx) avoids that. f.attributes() returns values in field order, so zipping with fields.names() produces a dictionary in one line, convenient for writing out JSON or building a pandas row. f.id() is the feature id the provider assigned — on GeoPackage the fid, on shapefiles a zero-based row number that can change when the file is rewritten — so it is a handle, not a stable business key.
Treat NULL values correctly
Empty attributes are not Python None in QGIS 3. They are NULL — a QVariant that is falsy but not equal to None — and that one detail breaks more comparisons than anything else in PyQGIS.
from qgis.core import NULL
for f in districts.getFeatures():
pop = f["population"]
if pop == NULL: # correct on QGIS 3
print(f["code"], "has no population value")
elif pop > 100_000:
print(f["code"], "is large")
# portable check, works on QGIS 3 and QGIS 4
def is_null(value):
return value is None or (hasattr(value, "isNull") and value.isNull())
Breakdown: On QGIS 3, f["population"] is None is always False for a NULL value, so a script that tests for None treats every empty value as present and then fails at pop > 100_000. Comparing with the NULL constant works, as does the truthiness test if not pop, though that also catches a genuine zero. QGIS 4 returns plain None for NULL values; the is_null helper works on both. Handling NULL values and QVariant goes through every case, including dates and writing NULL back.
Read geometry and its parts
f.geometry() returns a QgsGeometry. What you do with it depends on the geometry type: the as… methods return Python lists of points, while the vertices iterator walks every coordinate whatever the type.
from qgis.core import QgsWkbTypes
feature = next(districts.getFeatures())
geom = feature.geometry()
print(QgsWkbTypes.displayString(geom.wkbType()), geom.isMultipart())
if geom.isMultipart():
parts = geom.asMultiPolygon()
exterior = parts[0][0]
else:
exterior = geom.asPolygon()[0]
print(len(exterior), "vertices in the first exterior ring")
print("area", geom.area(), "centroid", geom.centroid().asPoint())
for v in geom.vertices():
print(v.x(), v.y(), v.z() if v.is3D() else "")
break
Breakdown: Calling asPolygon() on a multipolygon returns an empty list rather than raising, which is why checking isMultipart() first matters; many layers declared as MultiPolygon hold only single-part shapes, and code written against one sample can break on the next. The as… methods return 2D QgsPointXY objects. vertices() yields QgsPoint objects with Z and M preserved, and works the same on every type, which makes it the right choice for generic code. area() and length() are in layer units; for ellipsoidal measurements use QgsDistanceArea.
Fetch single features, ids and selections
Not every task needs the whole layer. Fetching one feature by id, the current selection, or a list of ids from an earlier step avoids a full scan.
from qgis.core import QgsFeatureRequest
one = districts.getFeature(17)
if not one.isValid():
print("no feature with id 17")
selected = districts.selectedFeatures() # list, all in memory
for f in districts.getSelectedFeatures(): # iterator, lazy
print(f["name"])
ids = [3, 17, 42]
for f in districts.getFeatures(QgsFeatureRequest().setFilterFids(ids)):
print(f.id(), f["code"])
Breakdown: getFeature(fid) returns an invalid feature rather than raising when the id does not exist — check isValid(). selectedFeatures() builds a list immediately, which is convenient for a handful and wasteful for a selection of a hundred thousand; getSelectedFeatures() iterates lazily. setFilterFids turns a list of ids into a single provider request, far faster than calling getFeature in a loop against a database.
Read only what you need
The provider reads every attribute and the geometry for every feature unless the request says otherwise. On wide tables and remote sources, restricting the request is the cheapest optimisation available.
request = (QgsFeatureRequest()
.setSubsetOfAttributes(["code", "population"], districts.fields())
.setFlags(QgsFeatureRequest.NoGeometry)
.setFilterExpression('"population" > 50000'))
total = sum(f["population"] for f in districts.getFeatures(request))
print("population in large districts:", total)
Breakdown: setSubsetOfAttributes takes names plus the fields object, and the provider fetches only those columns — on PostGIS this changes the SELECT list. Attributes outside the subset come back as NULL, so do not read them. NoGeometry skips decoding shapes entirely, often the most expensive part. The expression filter is translated into SQL by database providers where possible. The feature request performance guide measures each of these against real layers.
Avoid the reused-feature and live-edit pitfalls
Two iteration patterns corrupt results without raising an error. The first comes from older examples that reuse one QgsFeature object with nextFeature; the second is changing a layer while a loop is still reading it.
from qgis.core import QgsFeature, QgsGeometry
# pitfall 1: one feature object, refilled on every step
it = districts.getFeatures()
f = QgsFeature()
kept = []
while it.nextFeature(f):
kept.append(f) # the same object appended N times
print(len({id(x) for x in kept})) # 1 — every entry is the last feature
kept = [QgsFeature(x) for x in districts.getFeatures()] # explicit copies: safe
geoms = [QgsGeometry(x.geometry()) for x in districts.getFeatures()]
# pitfall 2: deleting inside the loop that reads the layer
to_delete = [x.id() for x in districts.getFeatures('"population" = 0')]
districts.dataProvider().deleteFeatures(to_delete)
Breakdown: nextFeature(f) fills the object you pass in, so appending f stores many references to one object whose contents end up as the last feature read. The plain for x in layer.getFeatures() form yields a new object on every step and has no such problem; copying with QgsFeature(x) or QgsGeometry(...) is cheap insurance when the objects will be kept and modified later. Collecting ids first and changing the layer afterwards is the general pattern for edits that depend on a scan: the iterator never sees a layer changing underneath it. Passing an expression string straight to getFeatures is shorthand for a request with setFilterExpression.
Order, limit and page through results
Some reads need a defined order — the ten most populous districts, features in the order a report lists them, or a stable sequence for paging through a remote layer. Iteration order is otherwise whatever the provider returns, which for a database is not guaranteed to be the same twice.
from qgis.core import QgsFeatureRequest
top = (QgsFeatureRequest()
.addOrderBy('"population"', ascending=False)
.setLimit(10)
.setFlags(QgsFeatureRequest.NoGeometry))
for rank, f in enumerate(districts.getFeatures(top), start=1):
print(rank, f["name"], f["population"])
# deterministic paging by id for a large remote layer
last_id, page_size = -1, 2000
while True:
page = QgsFeatureRequest().setFilterExpression(f"$id > {last_id}") \
.addOrderBy("$id").setLimit(page_size)
rows = list(districts.getFeatures(page))
if not rows:
break
process(rows) # your per-page work
last_id = rows[-1].id()
Breakdown: addOrderBy takes an expression, so ordering by a computed value such as "population" / "area_km2" works as well as ordering by a column; database providers translate simple cases into ORDER BY and QGIS sorts locally otherwise. setLimit stops the iterator after the given number of features and, on databases, becomes a LIMIT clause. Keyset paging — "everything after the last id I saw" — is more reliable than offset paging on a layer that may change between pages, and it keeps each request small, which matters for web services with response size limits.
QGIS version compatibility
Iteration, getFeature and QgsFeatureRequest are unchanged between QGIS 3 and QGIS 4, with two exceptions: NULL attributes are None on QGIS 4, and request flags are written Qgis.FeatureRequestFlag.NoGeometry. The short form QgsFeatureRequest.NoGeometry still works on 3.40 LTR.
Troubleshooting
- Every feature in my list is the same. The loop reused one object with
nextFeature; iterate withforor copy withQgsFeature(f). TypeError: '>' not supported between QVariant and int. The value is NULL; test with== NULLor theis_nullhelper first.- An attribute is always NULL. It is outside the requested attribute subset.
asPolygon()returns an empty list. The geometry is multipart; useasMultiPolygon()orvertices().
Conclusion
Iterate with getFeatures, read attributes by name for clarity and by index for speed, test NULL explicitly, choose the geometry accessor by type or use vertices(), fetch ids and selections without full scans, trim requests to the columns and rows you need, and never modify a layer while iterating it.
Frequently Asked Questions
How do I get a feature's attributes as a dict directly?dict(zip(layer.fields().names(), f.attributes())); on recent releases f.attributeMap() returns the same.
Is getFeatures() thread-safe?
Not on the same layer from several threads. In a background task, iterate a QgsVectorLayerFeatureSource created on the main thread.
How do I count features matching an expression without reading them?
Iterate a request with NoGeometry and an empty attribute subset; or use layer.aggregate for sums and counts.
Why are the ids not consecutive? Deleted features leave gaps, and some providers never use zero.