How to Add, Edit and Delete Features with PyQGIS
On this page
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:
sites = open_copy("first_try")
feature = QgsFeature(sites.fields())
feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(486000, 308700)))
print(sites.addFeature(feature)) # no edit session
print(sites.deleteFeature(12))
print(sites.featureCount()) # unchanged
False
False
531
No exception, no message. PyQGIS returns booleans where you expect exceptions, and every buffered 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
- a loop that deletes as it iterates removes only some of the features
- the layer's field list is stale, so
feature["new_field"]raises a KeyError
The examples edit a real layer: the 531 points of interest OpenStreetMap maps in Rutland β pubs, post boxes, benches, telephone boxes β as a GeoPackage in the British National Grid, with QGIS 4.2 running headless.
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 unattended bulk work,
layer.dataProvider()writes directly with no buffer and no undo
from qgis.core import QgsVectorLayer, QgsFeature, QgsGeometry, QgsPointXY
layer = open_copy("quick")
assert layer.isValid()
layer.startEditing()
feature = QgsFeature(layer.fields()) # correct field order, guaranteed
feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(486000, 308700)))
feature["name"] = "New depot"
feature["fclass"] = "depot"
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())
features now: 532
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
Add, change, delete, and verify what landed
featureCount() can change before anything is written to disk.Add features correctly
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 = open_copy("add")
new = [
make_feature(layer, QgsGeometry.fromPointXY(QgsPointXY(486000, 308700)),
{"name": "Depot A", "fclass": "depot"}),
make_feature(layer, QgsGeometry.fromPointXY(QgsPointXY(486500, 309200)),
{"name": "Depot B", "fclass": "depot"}),
]
ok, added = layer.dataProvider().addFeatures(new) # returns (bool, [features])
if not ok:
raise RuntimeError(layer.dataProvider().lastError())
print(ok, [f.id() for f in added], layer.featureCount())
try:
make_feature(layer, QgsGeometry(), {"name": "Depot C", "class": "depot"})
except KeyError as error:
print("KeyError:", error)
True [532, 533] 533
KeyError: "no such field: class (have ['fid', 'osm_id', 'fclass', 'name'])"
dataProvider().addFeatures() writes straight to the file β no edit session, no commit β and hands back the features with the ids the provider gave them. Assigning attributes positionally β feature.setAttributes([...]) β works but breaks the moment a field is added upstream; name-based assignment survives schema changes, and checking the name first turns a typo into an error instead of a lost value.
Change attributes and geometry
from qgis.core import QgsField, QgsFeatureRequest
from qgis.PyQt.QtCore import QMetaType
layer = open_copy("change")
layer.dataProvider().addAttributes([QgsField("reviewed", QMetaType.Type.Bool)])
layer.updateFields()
layer.startEditing()
idx = layer.fields().indexOf("reviewed")
for feature in layer.getFeatures(QgsFeatureRequest().setFilterExpression("\"fclass\" = 'pub'")):
layer.changeAttributeValue(feature.id(), idx, True)
# move a feature 50 m east
target = next(layer.getFeatures(QgsFeatureRequest().setFilterExpression("\"fclass\" = 'tourist_info'")))
moved = QgsGeometry(target.geometry())
moved.translate(50, 0)
layer.changeGeometry(target.id(), moved)
if not layer.commitChanges():
print(layer.commitErrors())
layer.rollBack()
reviewed = sum(1 for f in layer.getFeatures() if f["reviewed"])
print(reviewed, "pubs marked reviewed;", "moved:", layer.getFeature(target.id()).geometry().asPoint())
33 pubs marked reviewed; moved: <QgsPointXY: POINT(490286.67991906695533544 318529.71027260913979262)>
Both calls take a feature id, not an index. In a GeoPackage the id is the fid primary key; other providers assign their own, and they are not row numbers β get them from features you read, do not construct them.
Delete features
layer = open_copy("delete")
layer.startEditing()
doomed = [f.id() for f in layer.getFeatures(QgsFeatureRequest().setFilterExpression("\"fclass\" = 'telephone'"))]
print(f"deleting {len(doomed)} telephone boxes")
if not layer.deleteFeatures(doomed):
layer.rollBack()
raise RuntimeError("delete failed")
layer.commitChanges()
print(layer.featureCount(), "features left")
deleting 21 telephone boxes
510 features left
Collect the ids first and delete in one call. Deleting as you iterate is the classic "it deleted some of them" bug β and whether it bites depends on the provider:
from osgeo import gdal
benches = QgsFeatureRequest().setFilterExpression("\"fclass\" = 'bench'")
for label, path in [("GeoPackage", "work/iter.gpkg|layername=sites"), ("shapefile", "work/iter.shp")]:
if label == "shapefile":
gdal.VectorTranslate("work/iter.shp", "data/sites.gpkg", layers=["sites"])
else:
shutil.copy("data/sites.gpkg", "work/iter.gpkg")
target = QgsVectorLayer(path, label, "ogr")
provider = target.dataProvider()
for feature in target.getFeatures(benches):
provider.deleteFeatures([feature.id()]) # deleting under the iterator
target.reload()
left = sum(1 for _ in target.getFeatures(benches))
print(f"{label:10} benches left after the loop: {left} of 75")
GeoPackage benches left after the loop: 0 of 75
shapefile benches left after the loop: 61 of 75
On the GeoPackage every bench went. On the shapefile most survived: deleting a record under an open shapefile iterator changes what the iterator reads next. Code that happens to work on one format is a bug waiting for the other.
Use the provider directly for bulk work
The edit buffer keeps every change in memory to support undo. For a batch job that is overhead you do not need β though on a GeoPackage it is not a large one. Here are Rutland's 21,253 Land Registry parcels written as points three ways:
import time
points = [f.geometry().pointOnSurface() for f in parcels.getFeatures()]
def empty_target(name):
path = f"work/{name}.gpkg"
Path(path).unlink(missing_ok=True)
template = QgsVectorLayer("Point?crs=EPSG:27700&field=n:integer", name, "memory")
options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName, options.layerName = "GPKG", name
QgsVectorFileWriter.writeAsVectorFormatV3(template, path, QgsCoordinateTransformContext(), options)
return QgsVectorLayer(f"{path}|layername={name}", name, "ogr")
def features_for(layer):
out = []
for i, geometry in enumerate(points):
f = QgsFeature(layer.fields())
f.setGeometry(geometry)
f["n"] = i
out.append(f)
return out
for name, label in [("buffer_each", "buffer, one feature at a time"), ("buffer_list", "buffer, one list"),
("provider", "provider, one call")]:
target = empty_target(name)
batch = features_for(target)
start = time.perf_counter()
if label.startswith("provider"):
ok, _ = target.dataProvider().addFeatures(batch)
else:
target.startEditing()
if label.endswith("time"):
for f in batch:
target.addFeature(f)
else:
target.addFeatures(batch)
ok = target.commitChanges()
target.reload()
print(f"{label:30} {time.perf_counter() - start:5.2f} s ok={ok} {target.featureCount():,} features")
buffer, one feature at a time 0.15 s ok=True 21,253 features
buffer, one list 0.13 s ok=True 21,253 features
provider, one call 0.12 s ok=True 21,253 features
All three took the same fraction of a second. Choose between them on semantics, not speed: the provider path bypasses the undo stack, the layer's editing signals and anything listening to them β exactly right unattended, wrong for a layer open in a GUI or a plugin.
Add and remove fields
from qgis.core import QgsField
layer = open_copy("fields")
provider = layer.dataProvider()
provider.addAttributes([
QgsField("reviewed", QMetaType.Type.Bool),
QgsField("review_date", QMetaType.Type.QDate),
QgsField("score", QMetaType.Type.Double, len=10, prec=3),
])
stale = QgsFeature(layer.fields())
try:
stale["reviewed"] = True
except KeyError as error:
print("before updateFields(): KeyError", error)
layer.updateFields() # essential β the field list is cached
idx = layer.fields().indexOf("osm_id")
if idx != -1:
provider.deleteAttributes([idx])
layer.updateFields()
print(layer.fields().names())
before updateFields(): KeyError 'reviewed'
['fid', 'fclass', 'name', 'reviewed', 'review_date', 'score']
Forgetting updateFields() is why feature["reviewed"] raises KeyError immediately after you added the field. Field types are QMetaType values in QGIS 4; QGIS 3 code uses QVariant.Bool and friends.
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()
layer = open_copy("context")
post_boxes = [f.id() for f in layer.getFeatures(QgsFeatureRequest().setFilterExpression("\"fclass\" = 'post_box'"))]
with editing(layer, commit=False) as lyr: # a dry run
lyr.deleteFeatures(post_boxes)
print("inside the dry run:", lyr.featureCount())
print("after it:", layer.featureCount())
try:
with editing(layer) as lyr:
lyr.deleteFeatures(post_boxes)
raise ValueError("something went wrong half-way")
except ValueError:
pass
print("after the failed edit:", layer.featureCount(), "| still editing:", layer.isEditable())
inside the dry run: 471
after it: 531
after the failed edit: 531 | still editing: False
Passing commit=False gives you a dry run: every change is applied to the buffer β featureCount() reflects it β and then discarded.
Verify what actually landed
layer = open_copy("verify")
before = layer.featureCount()
new_features = [make_feature(layer, QgsGeometry.fromPointXY(QgsPointXY(486000 + i * 100, 308700)),
{"name": f"Site {i}", "fclass": "survey"}) for i in range(5)]
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"
531 β 536 (5 added, expected 5)
Counting before and after is the check that catches a provider silently rejecting features β for a constraint violation, a geometry type mismatch, or a field that does not exist.
Editing Rutland's points of interest safely
The data used on this page
Rutland's OpenStreetMap points of interest from Geofabrik's 1 January 2026 extract (Β© OpenStreetMap contributors, ODbL) β 531 points with osm_id, fclass and name β and its schools (polygons), both written into one GeoPackage in the British National Grid; and HM Land Registry's 21,253 Rutland freehold parcels (Open Government Licence) for the bulk-insert timing. open_copy() gives each example a fresh copy of the sites layer so no example sees another's edits. QGIS 4.2 is started once.
import os
import shutil
from pathlib import Path
import requests
from osgeo import gdal
gdal.UseExceptions()
Path("data").mkdir(exist_ok=True)
Path("work").mkdir(exist_ok=True)
if not Path("data/sites.gpkg").exists():
Path("data/rutland.shp.zip").write_bytes(requests.get(
"https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip", timeout=300).content)
gdal.VectorTranslate("data/sites.gpkg", "/vsizip/data/rutland.shp.zip/gis_osm_pois_free_1.shp",
layerName="sites", dstSRS="EPSG:27700", reproject=True,
selectFields=["osm_id", "fclass", "name"])
gdal.VectorTranslate("data/sites.gpkg", "/vsizip/data/rutland.shp.zip/gis_osm_pois_a_free_1.shp",
layerName="schools", dstSRS="EPSG:27700", reproject=True, accessMode="append",
SQLStatement="SELECT osm_id, fclass, name FROM gis_osm_pois_a_free_1 "
"WHERE fclass = 'school'",
geometryType="MULTIPOLYGON")
if not Path("data/parcels.gpkg").exists():
reply = requests.Session().get(
"https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300)
reply.raise_for_status()
Path("data/Rutland.zip").write_bytes(reply.content)
gdal.VectorTranslate("data/parcels.gpkg", "/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
layerName="parcels", selectFields=["INSPIREID"])
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import (Qgis, QgsApplication, QgsCoordinateTransformContext, QgsFeature, QgsFeatureRequest,
QgsGeometry, QgsPointXY, QgsProject, QgsVectorFileWriter, QgsVectorLayer)
qgs = QgsApplication([], False)
qgs.initQgis()
def open_copy(name):
"""A fresh copy of the sites layer, so each example starts from the same 531 points."""
path = Path(f"work/{name}.gpkg")
shutil.copy("data/sites.gpkg", path)
return QgsVectorLayer(f"{path}|layername=sites", name, "ogr")
parcels = QgsVectorLayer("data/parcels.gpkg|layername=parcels", "parcels", "ogr")
sites = open_copy("census")
print("QGIS", Qgis.QGIS_VERSION, "|", sites.featureCount(), "sites,", parcels.featureCount(), "parcels")
QGIS 4.2.2-BelΓ©m do ParΓ‘ | 531 sites, 21253 parcels
Example 1: a complete, safe editing helper
from qgis.core import QgsExpression
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()) - {"x", "y"}
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():
if key not in ("x", "y"):
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:
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:
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)
layer = open_copy("helpers")
report = add_features(
layer,
[{"name": "Depot C", "fclass": "depot", "x": 487000, "y": 309000}],
lambda r: QgsGeometry.fromPointXY(QgsPointXY(r["x"], r["y"])),
)
print(report)
print("named:", update_where(layer, "\"fclass\" = 'bench' AND \"name\" IS NULL", {"name": "(bench)"}))
print("deleted:", delete_where(layer, "\"fclass\" = 'waste_basket'"))
{'requested': 1, 'added': 1}
named: 73
deleted: 20
Example 2: build a memory layer from scratch
from qgis.core import QgsField
scratch = QgsVectorLayer("Point?crs=EPSG:27700", "scratch", "memory")
scratch.dataProvider().addAttributes([
QgsField("id", QMetaType.Type.Int),
QgsField("name", QMetaType.Type.QString),
QgsField("score", QMetaType.Type.Double),
])
scratch.updateFields()
rows = [(1, "A", 12.5, 486000, 308700), (2, "B", 8.25, 486500, 309200)]
features = []
for i, name, score, x, y in rows:
f = QgsFeature(scratch.fields())
f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(x, y)))
f["id"], f["name"], f["score"] = i, name, score
features.append(f)
scratch.dataProvider().addFeatures(features)
scratch.updateExtents()
options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName = "GPKG"
options.layerName = "scratch"
error, message, path, _ = QgsVectorFileWriter.writeAsVectorFormatV3(
scratch, "work/scratch.gpkg", QgsCoordinateTransformContext(), options)
print(error == QgsVectorFileWriter.WriterError.NoError, "wrote", scratch.featureCount(), "features to", path)
True wrote 2 features to work/scratch.gpkg
Memory layers are the natural staging area for constructed features β build, check, then write once.
Example 3: geometry editing operations
shutil.copy("data/sites.gpkg", "work/schools.gpkg")
schools = QgsVectorLayer("work/schools.gpkg|layername=schools", "schools", "ogr")
with editing(schools) as lyr:
for feature in lyr.getFeatures():
geom = feature.geometry()
if not geom.isGeosValid(): # repair
geom = geom.makeValid()
if geom.type() == Qgis.GeometryType.Polygon: # polygon: simplify
geom = geom.simplify(0.5)
lyr.changeGeometry(feature.id(), geom)
vertices = lambda layer: sum(len(list(f.geometry().vertices())) for f in layer.getFeatures())
print("vertices after simplify:", vertices(schools),
"| before:", vertices(QgsVectorLayer("data/sites.gpkg|layername=schools", "original", "ogr")))
# translate every feature 100 m east
first_x = next(schools.getFeatures()).geometry().boundingBox().xMinimum()
with editing(schools) as lyr:
for feature in lyr.getFeatures():
geom = feature.geometry()
geom.translate(100, 0)
lyr.changeGeometry(feature.id(), geom)
print("moved east by", round(next(schools.getFeatures()).geometry().boundingBox().xMinimum() - first_x, 3), "m")
vertices after simplify: 515 | before: 649
moved east by 100.0 m
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. In QGIS 4 geom.type() returns a Qgis.GeometryType; comparing it with 2, as QGIS 3 snippets do, is a comparison that no longer means what it did.
Example 4: transactional edits across related layers
With the project's transaction mode on, layers that share a connection are edited as one transaction β and a GeoPackage counts: both layers here live in one file.
shutil.copy("data/sites.gpkg", "work/both.gpkg")
project = QgsProject.instance()
project.setTransactionMode(Qgis.TransactionMode.AutomaticGroups)
sites_t = QgsVectorLayer("work/both.gpkg|layername=sites", "sites", "ogr")
schools_t = QgsVectorLayer("work/both.gpkg|layername=schools", "schools", "ogr")
project.addMapLayers([sites_t, schools_t])
before = (sites_t.featureCount(), schools_t.featureCount())
sites_t.startEditing() # starts a transaction on both layers
print("schools editable too:", schools_t.isEditable())
try:
sites_t.deleteFeatures([1, 2])
schools_t.deleteFeatures([1])
if not sites_t.commitChanges():
raise RuntimeError(sites_t.commitErrors())
except Exception:
sites_t.rollBack()
raise
print("sites", before[0], "β", sites_t.featureCount(), "| schools", before[1], "β", schools_t.featureCount())
project.clear()
project.setTransactionMode(Qgis.TransactionMode.Disabled)
schools editable too: True
sites 531 β 529 | schools 26 β 25
Automatic transaction groups work for database providers such as PostGIS, and for layers in the same GeoPackage or SpatiaLite file β either all the edits land or none do. For layers in separate files there is no shared connection; write to a temporary file and rename on success instead. setAutoTransaction(True) is the older spelling of the same switch.
The page's own session ends the way every script should β without these two lines the interpreter crashed with a segmentation fault on the way out, after every example had succeeded:
QgsProject.instance().clear()
qgs.exitQgis()
print("QGIS closed")
QGIS closed
Edit buffers, as in the GUI
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 β the dry run in the context-manager example showed it. 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 editing signals. For an unattended batch that is the right choice. For anything a user might want to undo, or a layer open in a GUI, the buffered path is correct. On a GeoPackage the two were equally fast for 21,253 inserts, so speed is not the reason to pick one.
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 through its provider while iterating it: on a shapefile that left most of the targeted features in place.
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 the stale field list does not have the new field at all. Constructing features with QgsFeature(layer.fields()) after updateFields(), and assigning by name, removes that class of bug.
Booleans, commit errors and schema updates
- 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 raisesKeyError.- Do not edit while iterating: Collect ids first, then act. A GeoPackage tolerated it here; a shapefile did not.
- 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, especially 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 in one call. On a GeoPackage, 21,253 points took the same fraction of a second through the edit buffer (one at a time or as a list) as through dataProvider().addFeatures(), so choose on semantics: the provider path skips undo and editing signals.
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 is the fid 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 depends on the provider: a GeoPackage coped, but on a shapefile most of Rutland's 75 benches survived the loop.
Can I make edits across several layers atomic?
Yes, when the layers share a connection β a PostGIS database, or one GeoPackage or SpatiaLite file. Set QgsProject.instance().setTransactionMode(Qgis.TransactionMode.AutomaticGroups) and they commit together. For layers in separate files, write to a temporary file and rename on success.