How to Add, Edit and Delete Features with PyQGIS
Problem statement
Editing in the QGIS GUI is a toggle, a click and a save. From a script it is a different model, and the first attempt usually fails silently:
layer.addFeature(feature) # returns False
layer.deleteFeature(12) # returns False
print(layer.featureCount()) # unchanged
No exception, no message. PyQGIS returns booleans where you expect exceptions, and every edit needs an editing session that must be started, filled and committed. Miss any part and the work quietly evaporates β or worse, half of it lands and half does not.
Related symptoms:
commitChanges()returnsFalseand the layer still has pending edits- edits apply to a memory copy rather than the file on disk
- a feature is added with no geometry, or with attributes in the wrong order
- adding 200,000 features takes hours because each one is a separate transaction
- the layer's field list is stale, so
feature["new_field"]raises a KeyError
Quick answer
Wrap edits in a session, check every return value, and commit once:
layer.startEditing()β begin a buffered edit session- build features with
QgsFeature(layer.fields())so the attribute order is right - add, change or delete, checking the boolean each call returns
layer.commitChanges()β and if it returnsFalse, readlayer.commitErrors()- for bulk work, go through
layer.dataProvider()instead and skip the buffer
from qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry, QgsPointXY
layer = QgsVectorLayer("data/sites.gpkg|layername=sites", "sites", "ogr")
assert layer.isValid()
layer.startEditing()
feature = QgsFeature(layer.fields()) # correct field order, guaranteed
feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(325000, 674000)))
feature["name"] = "New depot"
feature["class"] = "industrial"
if not layer.addFeature(feature):
layer.rollBack()
raise RuntimeError("addFeature failed")
if not layer.commitChanges():
errors = layer.commitErrors()
layer.rollBack()
raise RuntimeError("commit failed: " + "; ".join(errors))
print("features now:", layer.featureCount())
QgsFeature(layer.fields()) is the small detail that prevents most attribute bugs: it initialises the feature with the layer's exact field list, so name-based assignment works and the order can never drift.
The edit session
Step-by-step solution
Add features correctly
from qgis.core import QgsFeature, QgsGeometry, QgsPointXY
def make_feature(layer, geometry, attributes: dict) -> QgsFeature:
feature = QgsFeature(layer.fields())
feature.setGeometry(geometry)
for name, value in attributes.items():
if layer.fields().indexOf(name) == -1:
raise KeyError(f"no such field: {name} (have {layer.fields().names()})")
feature[name] = value
return feature
layer.startEditing()
new = [
make_feature(layer, QgsGeometry.fromPointXY(QgsPointXY(325000, 674000)),
{"name": "Depot A", "class": "industrial"}),
make_feature(layer, QgsGeometry.fromPointXY(QgsPointXY(326500, 675200)),
{"name": "Depot B", "class": "industrial"}),
]
ok, added = layer.dataProvider().addFeatures(new) # returns (bool, [features])
if not ok:
raise RuntimeError(layer.dataProvider().lastError())
layer.commitChanges()
Assigning attributes positionally β feature.setAttributes(["Depot A", "industrial"]) β works but breaks the moment a field is added upstream. Name-based assignment survives schema changes.
Change attributes and geometry
layer.startEditing()
for feature in layer.getFeatures():
if feature["class"] == "industrial":
idx = layer.fields().indexOf("reviewed")
layer.changeAttributeValue(feature.id(), idx, True)
# move a feature
target = next(layer.getFeatures('"name" = \'Depot A\''))
layer.changeGeometry(target.id(), QgsGeometry.fromPointXY(QgsPointXY(325100, 674050)))
if not layer.commitChanges():
print(layer.commitErrors())
layer.rollBack()
Both calls take a feature id, not an index. Feature ids come from the provider, are stable within a session, and are not row numbers β do not construct them.
Delete features
layer.startEditing()
doomed = [f.id() for f in layer.getFeatures() if f["status"] == "obsolete"]
print(f"deleting {len(doomed)} features")
if not layer.deleteFeatures(doomed):
layer.rollBack()
raise RuntimeError("delete failed")
layer.commitChanges()
Collect the ids first and delete in one call. Deleting while iterating invalidates the iterator, which produces results that vary by provider β the classic "it deleted half of them" bug.
Use the provider directly for bulk work
The edit buffer keeps every change in memory to support undo. For a batch job that is pure overhead.
from qgis.core import QgsFeature, QgsGeometry, QgsPointXY
provider = layer.dataProvider()
batch, BATCH = [], 10_000
for row in rows: # a large external source
f = QgsFeature(layer.fields())
f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(row["x"], row["y"])))
f["name"] = row["name"]
batch.append(f)
if len(batch) >= BATCH:
ok, _ = provider.addFeatures(batch)
if not ok:
raise RuntimeError(provider.lastError())
batch.clear()
if batch:
provider.addFeatures(batch)
layer.updateExtents()
print(layer.featureCount(), "features")
Provider writes bypass undo and validation, which is exactly what you want unattended β and roughly an order of magnitude faster on large inserts.
Add and remove fields
from qgis.core import QgsField
from qgis.PyQt.QtCore import QVariant
provider = layer.dataProvider()
provider.addAttributes([
QgsField("reviewed", QVariant.Bool),
QgsField("review_date", QVariant.Date),
QgsField("score", QVariant.Double, len=10, prec=3),
])
layer.updateFields() # essential β the field list is cached
idx = layer.fields().indexOf("obsolete_col")
if idx != -1:
provider.deleteAttributes([idx])
layer.updateFields()
print(layer.fields().names())
Forgetting updateFields() is why feature["reviewed"] raises KeyError immediately after you added the field.
Wrap it all in a context manager
Manual startEditing / commitChanges pairs get separated by exceptions. A context manager makes the rollback automatic.
from contextlib import contextmanager
@contextmanager
def editing(layer, commit: bool = True):
"""Edit session that rolls back on any exception."""
if not layer.startEditing():
raise RuntimeError(f"cannot start editing {layer.name()} β is it read-only?")
try:
yield layer
except Exception:
layer.rollBack()
raise
else:
if commit and not layer.commitChanges():
errors = layer.commitErrors()
layer.rollBack()
raise RuntimeError("commit failed: " + "; ".join(errors))
elif not commit:
layer.rollBack()
with editing(layer) as lyr:
lyr.addFeature(make_feature(lyr, geom, {"name": "Depot C"}))
lyr.deleteFeatures(doomed)
Passing commit=False gives you a dry run: every change is applied to the buffer, validated, and then discarded.
Verify what actually landed
before = layer.featureCount()
with editing(layer) as lyr:
ok, added = lyr.dataProvider().addFeatures(new_features)
layer.reload()
after = layer.featureCount()
print(f"{before} β {after} ({after - before} added, expected {len(new_features)})")
assert after - before == len(new_features), "some features did not land"
Counting before and after is the only check that catches a provider silently rejecting features β for a constraint violation, a geometry type mismatch, or a field that does not exist.
Code examples
Example 1: a complete, safe editing helper
"""edit.py β add, update and delete features with real error reporting."""
from contextlib import contextmanager
from qgis.core import QgsFeature, QgsGeometry, QgsVectorLayer
@contextmanager
def editing(layer: QgsVectorLayer, commit: bool = True):
if not layer.startEditing():
raise RuntimeError(f"{layer.name()}: cannot start an edit session")
try:
yield layer
except Exception:
layer.rollBack()
raise
if commit:
if not layer.commitChanges():
errors = layer.commitErrors()
layer.rollBack()
raise RuntimeError(f"{layer.name()}: commit failed β {'; '.join(errors)}")
else:
layer.rollBack()
def add_features(layer, records, geometry_fn) -> dict:
"""records: iterable of dicts; geometry_fn: dict β QgsGeometry."""
fields = layer.fields()
unknown = {k for r in records for k in r} - set(fields.names())
if unknown:
raise KeyError(f"fields not on the layer: {sorted(unknown)}")
features = []
for record in records:
f = QgsFeature(fields)
geom = geometry_fn(record)
if geom is None or geom.isEmpty():
raise ValueError(f"no geometry for record {record}")
f.setGeometry(geom)
for key, value in record.items():
f[key] = value
features.append(f)
before = layer.featureCount()
ok, added = layer.dataProvider().addFeatures(features)
if not ok:
raise RuntimeError(f"provider rejected the features: {layer.dataProvider().lastError()}")
layer.updateExtents()
layer.reload()
return {"requested": len(features), "added": layer.featureCount() - before}
def update_where(layer, expression: str, updates: dict) -> int:
from qgis.core import QgsFeatureRequest, QgsExpression
idx = {name: layer.fields().indexOf(name) for name in updates}
if -1 in idx.values():
raise KeyError(f"unknown field in {list(updates)}")
changes = {}
for feature in layer.getFeatures(QgsFeatureRequest(QgsExpression(expression))):
changes[feature.id()] = {idx[name]: value for name, value in updates.items()}
if changes:
layer.dataProvider().changeAttributeValues(changes)
layer.reload()
return len(changes)
def delete_where(layer, expression: str) -> int:
from qgis.core import QgsFeatureRequest, QgsExpression
ids = [f.id() for f in layer.getFeatures(QgsFeatureRequest(QgsExpression(expression)))]
if ids and not layer.dataProvider().deleteFeatures(ids):
raise RuntimeError("delete failed")
layer.reload()
return len(ids)
from qgis.core import QgsGeometry, QgsPointXY
report = add_features(
layer,
[{"name": "Depot C", "class": "industrial", "x": 327000, "y": 676000}],
lambda r: QgsGeometry.fromPointXY(QgsPointXY(r.pop("x"), r.pop("y"))),
)
print(report)
print("updated:", update_where(layer, '"class" = \'industrial\'', {"reviewed": True}))
print("deleted:", delete_where(layer, '"status" = \'obsolete\''))
Example 2: build a memory layer from scratch
from qgis.core import (QgsVectorLayer, QgsField, QgsFeature, QgsGeometry,
QgsPointXY, QgsProject, QgsVectorFileWriter,
QgsCoordinateTransformContext)
from qgis.PyQt.QtCore import QVariant
layer = QgsVectorLayer("Point?crs=EPSG:27700", "scratch", "memory")
layer.dataProvider().addAttributes([
QgsField("id", QVariant.Int),
QgsField("name", QVariant.String),
QgsField("score", QVariant.Double),
])
layer.updateFields()
rows = [(1, "A", 12.5, 325000, 674000), (2, "B", 8.25, 326000, 675000)]
features = []
for i, name, score, x, y in rows:
f = QgsFeature(layer.fields())
f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(x, y)))
f["id"], f["name"], f["score"] = i, name, score
features.append(f)
layer.dataProvider().addFeatures(features)
layer.updateExtents()
options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName = "GPKG"
options.layerName = "scratch"
QgsVectorFileWriter.writeAsVectorFormatV3(
layer, "data/out/scratch.gpkg", QgsCoordinateTransformContext(), options)
print("wrote", layer.featureCount(), "features")
Memory layers are the natural staging area for constructed features β build, check, then write once.
Example 3: geometry editing operations
from qgis.core import QgsGeometry, QgsPointXY
with editing(layer) as lyr:
for feature in lyr.getFeatures():
geom = feature.geometry()
if not geom.isGeosValid(): # repair
geom = geom.makeValid()
if geom.type() == 2: # polygon: simplify
geom = geom.simplify(0.5)
lyr.changeGeometry(feature.id(), geom)
# translate every feature 100 m east
with editing(layer) as lyr:
for feature in lyr.getFeatures():
geom = feature.geometry()
geom.translate(100, 0)
lyr.changeGeometry(feature.id(), geom)
QgsGeometry methods split into two kinds: some mutate in place (translate, transform), others return a new geometry (makeValid, simplify, buffer). Mixing them up produces edits that appear to do nothing.
Example 4: transactional edits across related layers
from qgis.core import QgsProject
project = QgsProject.instance()
project.setAutoTransaction(True) # QGIS 3.x; setTransactionMode() in newer builds
parcels = project.mapLayersByName("parcels")[0]
owners = project.mapLayersByName("owners")[0]
parcels.startEditing() # starts a transaction on both, same connection
try:
parcels.deleteFeatures([12, 13])
owners.deleteFeatures([44])
if not parcels.commitChanges():
raise RuntimeError(parcels.commitErrors())
except Exception:
parcels.rollBack()
raise
Automatic transactions work with database providers such as PostGIS, where several layers share a connection β either all the edits land or none do. File-based providers do not offer this, so for GeoPackage deliverables write to a temporary file and rename on success instead.
Explanation
PyQGIS models editing the way the GUI does, because it is the same code. startEditing() opens a buffer that records added, changed and deleted features in memory; the layer then reports the buffered state, so featureCount() changes immediately even though nothing has been written. commitChanges() asks the provider to apply the buffer, and rollBack() discards it.
That design explains the two most common surprises. First, edits without a commit vanish: the buffer is discarded when the layer is destroyed, and no error is raised. Second, commitChanges() can fail for reasons the individual calls could not have known β a constraint violation, a geometry type the provider rejects, a read-only file β which is why commitErrors() exists and why ignoring the return value hides real failures.
The data provider is the layer beneath. layer.dataProvider().addFeatures(), changeAttributeValues() and deleteFeatures() write straight through, with no buffer, no undo and no signal traffic. For an unattended batch inserting hundreds of thousands of features that is the right choice: one call per batch instead of one buffered change per feature, and memory that stays flat. For anything a user might want to undo, or a layer open in a GUI, the buffered path is correct.
Feature ids are the other thing to internalise. They are provider-assigned handles, not row numbers, and every edit call takes one. Get them from features you actually read β through getFeatures() or a QgsFeatureRequest β rather than constructing them, and never mutate a layer while iterating it, because the iterator's behaviour after a change is provider-specific.
Finally, keep the field list fresh. Fields are cached on the layer, so addAttributes() on the provider is invisible until updateFields() runs, and a QgsFeature built from a stale field list will silently put values in the wrong columns. Constructing features with QgsFeature(layer.fields()) and assigning by name removes that entire class of bug.
Edge cases or notes
- Return values, not exceptions:
addFeature,changeAttributeValue,deleteFeaturesandcommitChangesall return booleans. Ignoring them is how edits disappear silently. commitErrors()is the diagnostic: After a failed commit it lists the constraint or provider errors. Print it before rolling back.updateFields()after schema changes: Otherwise the layer's cached field list is stale and name-based assignment fails.- Do not edit while iterating: Collect ids first, then act. Iterator behaviour after a modification is undefined across providers.
- Some geometry methods mutate, some return:
translateandtransformchange in place;buffer,simplifyandmakeValidreturn new geometries. - Provider capabilities vary: Check
layer.dataProvider().capabilities()β a read-only or CSV-backed layer cannot accept edits at all. - GeoPackage locking: A layer open in the QGIS GUI can block a writing script on Windows. Close the project or work on a copy.
Internal links
- How to Automate QGIS with Python (PyQGIS): The Complete Workflow
- How to Run Field Calculator Expressions from PyQGIS
- How to Build and Save a QGIS Project File from Python
- How to Move Data Between QGIS and GeoPandas
- PyQGIS Layer Fails to Load (isValid() Returns False): How to Fix It
- PyQGIS Script Crashes with a Segmentation Fault: How to Fix It
FAQ
Why does addFeature() return False?
Usually the layer is not in an edit session, is read-only, or the feature's fields do not match the layer's. Check layer.isEditable(), build features with QgsFeature(layer.fields()), and read layer.dataProvider().lastError().
Do I need startEditing() if I use the data provider?
No. dataProvider().addFeatures() writes directly and needs no session. You lose undo and buffered validation, which is the trade-off you want in a batch job.
Why did my edits disappear?
commitChanges() was never called, or it returned False and the result was ignored. Always check the return value and print commitErrors() when it fails.
How do I add features quickly?
Build a list of QgsFeature objects and pass them to dataProvider().addFeatures() in batches of a few thousand. Per-feature buffered edits are roughly an order of magnitude slower.
What is a feature id, and can I choose it?
It is a provider-assigned handle used to reference a feature in edit calls. Get it from a feature you have read; do not invent one. For GeoPackage it maps to the primary key, but that is not guaranteed across providers.
How do I delete features matching a condition?
Collect the ids first with a QgsFeatureRequest(QgsExpression(...)), then call deleteFeatures(ids) once. Deleting inside the iteration invalidates the iterator.
Can I make edits across several layers atomic?
With database providers, yes β enable automatic transactions on the project so layers sharing a connection commit together. File-based providers have no equivalent; write to a temporary file and rename on success.