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() 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
  • 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:

  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 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

Vertical steps: startEditing, buffer changes, validate, commitChanges or rollBack.
Four states β€” everything between start and commit lives in memory only.

Step-by-step solution

Panels showing the edit buffer holding changes before they reach the data provider.
The buffer is why `featureCount()` can change before anything is written to disk.

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.

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.

Grid comparing edit-buffer API and data provider API across undo, speed, validation and use case.
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 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, 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 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: 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 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 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.