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.

From a request to values in PythonA QgsFeatureRequest describes which features and which columns to fetch, with a filter rectangle, an expression or a list of ids. layer.getFeatures passes it to the data provider, which returns a QgsFeatureIterator. Each step yields a QgsFeature holding an attribute list and a geometry. Python reads values by name, by index or as a dictionary, and reads the geometry through asPoint, asPolyline, asPolygon or the vertices iterator.What getFeatures actually hands youQgsFeatureRequestwhich features?which columns?data providerreads onlywhat was askedQgsFeature, one at a timeattributes() → listgeometry() → QgsGeometryvaluesf["name"] · f.attribute(i)shapeasPoint() · vertices()

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series.
  • A loaded vector layer. The examples use a polygon layer called districts with fields code, name, population and area_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.

Which accessor for which geometryPoint geometry: asPoint returns one QgsPointXY; asMultiPoint returns a list. Line geometry: asPolyline returns a list of points; asMultiPolyline returns a list of lists. Polygon geometry: asPolygon returns a list of rings, the first being the exterior; asMultiPolygon adds another level of nesting. The vertices iterator works for all types and keeps Z and M values, which the as methods drop.The as… methods follow the nesting of the typepointasPoint()→ QgsPointXYasMultiPoint()→ list of pointslineasPolyline()→ list of pointsasMultiPolyline()→ list of listspolygonasPolygon()→ list of ringsasMultiPolygon()→ list of polygonsvertices() — every type, keeps Z and M

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.

A request that asks for lessDefault request: all forty columns and the full geometry are read for every feature. Trimmed request: setSubsetOfAttributes limits columns to code and population, the NoGeometry flag skips geometry decoding, and setFilterExpression or setFilterRect limits rows. The trimmed request reads a small fraction of the data and runs proportionally faster on databases and web services.Columns × geometry × rowsdefault requestall 40 columnsfull geometry decodedevery rowslow on wide or remote datatrimmed requestsetSubsetOfAttributes(…)setFlags(NoGeometry)setFilterExpression(…)reads a fraction

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 with for or copy with QgsFeature(f).
  • TypeError: '>' not supported between QVariant and int. The value is NULL; test with == NULL or the is_null helper first.
  • An attribute is always NULL. It is outside the requested attribute subset.
  • asPolygon() returns an empty list. The geometry is multipart; use asMultiPolygon() or vertices().

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.