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() returns False and 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:

  1. layer.startEditing() β€” begin a buffered edit session
  2. build features with QgsFeature(layer.fields()) so the attribute order is right
  3. add, change or delete, checking the boolean each call returns
  4. layer.commitChanges() β€” and if it returns False, read layer.commitErrors()
  5. 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

The PyQGIS edit session from startEditing to commit or rollback. four states1startEditing()opens the buffer2add / change / deletecheck every bool3validate the resultcounts, geometry4commitChanges()or read commitErrors5rollBack() on failurein a finally blockEverything between start and commit lives in memory only spatialworkflow.io
Four states β€” everything between start and commit lives in memory only.

Add, change, delete, and verify what landed

The edit buffer holding changes before they reach the data provider. why featureCount() liesbuffered editingβœ“changes held in memoryβœ“featureCount() already updatedβœ“undo availableβœ“nothing on disk until commitprovider writesβœ“straight to the data sourceβœ“no session neededβœ“no undo, no validationβœ“an order of magnitude fasterChoose by whether a human might want to undo the change spatialworkflow.io
The buffer is why 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.

Deleting features through the provider inside the loop that reads them, on a GeoPackage and on a shapefile. delete 75 benches inside the loop that finds themformatbenches leftresultGeoPackage0 of 75all deletedshapefile61 of 7514 deleted, no errorCollect the ids first; then the format does not matter spatialworkflow.io
Measured above under QGIS 4.2. The same loop, two formats, two different outcomes.

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.

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
What a QGIS transaction group did for two layers of one GeoPackage. one GeoPackage, two layers, one transactionstartEditing() on sitesschools became editable toodelete 2 sites, 1 schoolboth in the same bufferone commitChanges()sites 531 β†’ 529, schools 26 β†’ 25layers in separate filesno shared connection, no groupStarting an edit on one layer made the other editable spatialworkflow.io
Measured in Example 4: a GeoPackage is a shared connection, so its layers can commit together.

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.

The edit-buffer API compared with the data provider API. undo versus no undolayer.addFeatureprovider.addFeaturesneeds startEditing!yesβœ“noundo availableβœ“yesβœ•no21,253 inserts, GPKG0.15 s0.14 suse it inGUI pluginsbatch scriptsBoth return booleans β€” ignoring them is how edits disappear spatialworkflow.io
Two APIs for the same operations β€” the choice is undo versus throughput.

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, deleteFeatures and commitChanges all 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 raises KeyError.
  • 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: translate and transform change in place; buffer, simplify and makeValid return 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.

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.