Iterate and Edit Geometry Vertices in PyQGIS
Most geometry work in PyQGIS happens at the level of whole shapes: buffer this, intersect that, simplify the result. Some jobs need to go underneath — to the individual coordinates. Finding polygons with more than 10,000 vertices that make a web service choke, spotting spikes where a digitiser double-clicked, rounding coordinates to the survey's actual precision, stripping Z values that a CAD export added, nudging one vertex that sits a centimetre off a boundary. All of those mean walking the vertices of a geometry and, sometimes, changing them.
This recipe belongs to Geometry Operations and Spatial Predicates in PyQGIS. It explains how geometries nest, reads vertices with their positions in that structure, edits them in place, cleans coordinates in bulk, and writes the result back to the layer.
Prerequisites
- QGIS 3.40 LTR or newer, or the QGIS 4 series.
- An editable vector layer for the editing sections; reading works on any layer.
- Awareness that editing coordinates changes the data permanently once committed. Work on a copy until the script is proven.
Read every vertex with its position
QgsGeometry.vertices() iterates every vertex as a QgsPoint, flattening parts and rings. That is enough for counting and bounding checks. When you need to know where in the structure a vertex sits — which part, which ring — walk the vertex ids alongside.
from qgis.core import QgsProject
parcels = QgsProject.instance().mapLayersByName("parcels")[0]
feature = next(parcels.getFeatures())
geom = feature.geometry()
print("vertices:", sum(1 for _ in geom.vertices()), "parts:", geom.constGet().partCount())
for flat_index in range(geom.constGet().nCoordinates()):
ok, vid = geom.vertexIdFromVertexNr(flat_index)
if not ok:
break
point = geom.vertexAt(flat_index)
print(f"part {vid.part} ring {vid.ring} vertex {vid.vertex} "
f"(#{flat_index}): {point.x():.2f}, {point.y():.2f}")
Breakdown: constGet() returns the underlying QgsAbstractGeometry without copying it, which is what exposes structural methods such as partCount and nCoordinates. vertexIdFromVertexNr converts a flat vertex index — the kind vertexAt, moveVertex and the other QgsGeometry editing methods use — into a QgsVertexId holding part, ring and vertex numbers, and vertexNrFromVertexId goes the other way. Keeping both is how you move between "the third vertex of the hole" and "vertex 14". Rings are closed, so a square ring reports five vertices with the first repeated at the end; count distinct positions when a human-facing number matters.
For simple geometries the nested Python lists are often more convenient: asPolyline(), asPolygon() and asMultiPolygon() return lists of QgsPointXY, nested one level per structure. They drop Z and M values, so use the vertex iterator when those matter.
Find problem geometries by their vertices
Vertex-level checks catch problems that validity checks do not: a geometry can be perfectly valid and still have 40,000 vertices along a straight fence, or a spike that doubles back on itself.
import math
def turn_angles(points):
for a, b, c in zip(points, points[1:], points[2:]):
v1 = (a.x() - b.x(), a.y() - b.y())
v2 = (c.x() - b.x(), c.y() - b.y())
n1, n2 = math.hypot(*v1), math.hypot(*v2)
if n1 == 0 or n2 == 0:
yield b, 0.0
continue
cos = max(-1.0, min(1.0, (v1[0] * v2[0] + v1[1] * v2[1]) / (n1 * n2)))
yield b, math.degrees(math.acos(cos))
report = []
for f in parcels.getFeatures():
g = f.geometry()
count = g.constGet().nCoordinates()
per_m = count / max(g.length(), 1.0)
points = list(g.vertices())
duplicates = sum(1 for p, q in zip(points, points[1:]) if p == q)
spikes = [p for p, ang in turn_angles(points) if 0 < ang < 5]
if count > 5000 or per_m > 2 or duplicates or spikes:
report.append((f["parcel_ref"], count, round(per_m, 2), duplicates, len(spikes)))
for row in sorted(report, key=lambda r: -r[1])[:25]:
print(row)
Breakdown: nCoordinates() counts every vertex including ring closures and is cheap because it does not build point objects. Vertex density — vertices per metre of perimeter — is a better signal than raw count, because a large rural parcel legitimately has more vertices than a small urban one. Consecutive identical points are true duplicates; a turn angle between two consecutive segments of only a few degrees means the boundary went out and came almost straight back, which is the signature of a spike. A zero angle is reported for zero-length segments, which are duplicates by another name, so the spike test excludes it. The thresholds belong to your data: a hand-digitised cadastre and a GPS track need different numbers.
Edit individual vertices
QgsGeometry has methods to move, insert and delete vertices by flat index. Each changes the geometry object in place and returns a success flag; nothing touches the layer until you write the geometry back.
from qgis.core import QgsGeometry, QgsPointXY
g = QgsGeometry(feature.geometry())
ok = g.moveVertex(412503.25, 287114.80, 7)
print("moved:", ok)
ok = g.insertVertex(412510.00, 287120.00, 8)
print("inserted before vertex 8:", ok)
ok = g.deleteVertex(3)
print("deleted vertex 3:", ok)
closest_point, index, before, after, sq_dist = g.closestVertex(QgsPointXY(412505, 287116))
print("closest vertex index", index, "at", closest_point, "distance", sq_dist ** 0.5)
Breakdown: Copying the geometry first with QgsGeometry(…) keeps the original feature's geometry untouched, so you can compare before and after. moveVertex has an overload taking a QgsPoint, which preserves Z and M. insertVertex inserts before the given index, shifting later indices up by one — so when inserting several vertices, work from the highest index down, or indices drift under you. deleteVertex refuses deletions that would leave an invalid ring, such as reducing a triangle to two vertices. closestVertex returns the nearest vertex, its index, its neighbours and the squared distance, which is the usual way to pick a vertex from a map click in a custom tool like the one in snapping to features with QgsSnappingUtils.
Clean coordinates in bulk
Rather than touching vertices one at a time, several whole-geometry methods rewrite every vertex at once: removing duplicates, snapping to a grid, and dropping Z or M.
def clean(geom, grid=0.01, keep_z=False):
g = QgsGeometry(geom)
g.removeDuplicateNodes(1e-8)
g = g.snappedToGrid(grid, grid)
g.removeDuplicateNodes(1e-8)
abstract = g.get()
if not keep_z:
abstract.dropZValue()
abstract.dropMValue()
if not g.isGeosValid():
g = g.makeValid()
return g
before = feature.geometry()
after = clean(before)
print(before.constGet().nCoordinates(), "→", after.constGet().nCoordinates(),
"area change:", round(after.area() - before.area(), 4), "m²")
Breakdown: Snapping to a one-centimetre grid rounds every coordinate to the precision the survey actually has, which removes meaningless digits and makes near-identical vertices exactly identical — hence the second duplicate removal. get() returns a mutable pointer to the abstract geometry, unlike constGet(), and is what allows dropping Z and M in place. Snapping can create self-intersections where two vertices of a thin sliver collapse together, so validity is checked last and repaired with makeValid, as covered in finding and fixing invalid geometries. Reporting the area change is a sanity check: at centimetre precision it should be a fraction of a square metre.
Write edited geometries back
Edited geometries live only in Python until they are written to the layer. An edit session groups the changes so they commit together or roll back together.
from qgis.core import edit
changed = 0
with edit(parcels):
for f in parcels.getFeatures():
new_geom = clean(f.geometry())
if not new_geom.equals(f.geometry()):
parcels.changeGeometry(f.id(), new_geom)
changed += 1
print(changed, "geometries changed")
Breakdown: Comparing with equals skips features the clean-up did not alter, which keeps the edit buffer small and the undo history meaningful. changeGeometry checks that the new geometry type is compatible with the layer — dropping Z from a PolygonZ layer is refused, so change the layer's geometry type by writing a new layer instead in that case. The edit context manager commits on success and rolls back if anything raises, as in editing features with transactions. For a read-only exploration of vertices as their own features, native:extractvertices produces a point layer with part, ring and vertex index fields — useful for styling or labelling vertices on the map.
QGIS version compatibility
vertices(), nextVertex, moveVertex, insertVertex, deleteVertex and closestVertex have been available since QGIS 3.0. removeDuplicateNodes arrived in 3.4 and snappedToGrid in 3.2. makeValid uses GEOS's implementation from 3.16, and gained a method argument in 3.28. All of them work identically on 3.40, 3.44 and the QGIS 4 series.
Troubleshooting
- Indices shift after inserting. Insert from the highest index down.
moveVertexreturns False. The index is beyond the vertex count; check againstnCoordinates().changeGeometryfails. The geometry type no longer matches the layer, for example after dropping Z.- Z values disappear.
asPolylineorasPolygonwas used; they return 2D points. - Area changes noticeably after snapping. The grid is too coarse for the data's scale.
Conclusion
Iterate with vertices() for simple passes and nextVertex when you need parts and rings, convert between structured and flat indices as needed, and use vertex density, duplicates and turn angles to find geometries that are valid but wrong. Edit on a copy, clean in bulk with duplicate removal, grid snapping and a final validity check, and write back only the geometries that changed inside one edit session.
Frequently Asked Questions
How do I count vertices for every feature quickly?
Use feature.geometry().constGet().nCoordinates(), or the expression num_points($geometry) in a field calculation.
Can I iterate vertices of curved geometries?
Yes. vertices() returns the control points of curves; segmentize with densifyByCount or segmentize if you need the approximated line.
What is the difference between get() and constGet()?constGet() returns a read-only view without copying; get() detaches a copy that you may modify in place.
Does snapping to a grid preserve topology between neighbours? Mostly, because shared vertices round to the same grid point. Check with a topology validation afterwards if boundaries must stay coincident.