Edit Features with Transactions in PyQGIS

There are two ways to change a feature in PyQGIS and they behave completely differently. The edit buffer — startEditing, change, commitChanges — stages everything in memory, participates in undo, and can be rolled back. The data provider — dataProvider().changeAttributeValues() — writes immediately, bypasses undo, and cannot be undone. Choosing the wrong one is how a script leaves a layer half-modified with no way back.

This recipe belongs to Vector Data Manipulation. It covers both paths, when each is right, rollback, performance, and transaction groups that span several database layers.

Staged, or straight throughEdits made through the layer go into an in-memory buffer, take part in the undo stack, and reach the data source only on commit; a rollback discards them. Edits made through the data provider go straight to the data source with no buffer, no undo and no way back.One of these has a way backlayer.startEditing()your changesedit buffer + undocommitChanges()rollBack()nothing on disk until you commitlayer.dataProvider()your changesthe file, immediatelyno buffer · no undo · no rollbackfaster on bulk writesan interrupted loop leaves half the changesbuffer for anything a person will see; provider for bulk work on a copy

Prerequisites

  • QGIS 3.34 LTR or newer.
  • An editable layer — a GeoPackage, PostGIS table or shapefile. A layer opened read-only reports isEditable() false and refuses startEditing.

The edit buffer

from qgis.core import QgsProject

parcels = QgsProject.instance().mapLayersByName("parcels")[0]
index = parcels.fields().indexOf("status")

if not parcels.startEditing():
    raise SystemExit("layer is not editable")

try:
    for feature in parcels.getFeatures('"area_m2" > 5000'):
        parcels.changeAttributeValue(feature.id(), index, "large")
    if not parcels.commitChanges():
        raise RuntimeError("; ".join(parcels.commitErrors()))
except Exception:
    parcels.rollBack()
    raise

Breakdown: startEditing() returns False rather than raising when the layer cannot be edited, so the check earns its place — a read-only source otherwise produces a loop that appears to work and commits nothing. commitChanges() also returns a boolean, and commitErrors() holds the reasons, which are usually a constraint violation or a provider that rejected a type. The try/except with rollBack() is what makes this safe: without it, an exception halfway through leaves the layer in edit mode with a partial buffer, and the next code to touch it inherits that state. Passing an expression to getFeatures filters at the provider, so only the matching features are fetched.

Rolling back discards the buffer entirely — it is not a partial undo, and there is no way to keep some changes and drop others except by not making them.

The provider path

provider = parcels.dataProvider()

updates = {}
for feature in parcels.getFeatures('"area_m2" > 5000'):
    updates[feature.id()] = {index: "large"}

provider.changeAttributeValues(updates)

Breakdown: One call carrying a dictionary of feature id to attribute changes is dramatically faster than a loop of individual writes, because the provider batches them into one statement or one file operation. There is no buffer, so the change is on disk when the call returns and there is nothing to commit. There is also nothing to undo, no signal that the layer changed in the way the buffer emits, and no participation in a transaction — which is exactly why this belongs in a script working on a copy and not in a plugin operating on a user's data.

Geometry is the same shape:

from qgis.core import QgsGeometry

geometries = {
    feature.id(): feature.geometry().buffer(2.0, 8)
    for feature in parcels.getFeatures()
}
provider.changeGeometryValues(geometries)

Breakdown: Building the whole dictionary before writing costs memory proportional to the layer, so on a very large layer chunk it into batches of a few thousand. buffer(distance, segments) returns a new geometry rather than modifying in place, which is why the comprehension works. Note that this bypasses any geometry validity checking the layer might otherwise apply.

Performance and why loops are slow

Where the time actually goesCommitting after every feature pays the provider's write cost once per feature and is orders of magnitude slower. One commit at the end of an edit session pays it once. A single batched provider call is faster still, at the cost of losing undo and rollback.10,000 attribute changescommit per featureminutesone commit at the enda few secondsbatched provider callunder a secondthe middle one is usually the right trade — fast enough, and undoable

The pattern that ruins performance is committing inside the loop. Each commit flushes to the provider, and on a database that is a round trip per feature. Keeping the edit session open for the whole loop and committing once turns thousands of round trips into one.

The second cost is fetching more than you need:

from qgis.core import QgsFeatureRequest

request = QgsFeatureRequest()
request.setFlags(QgsFeatureRequest.NoGeometry)
request.setSubsetOfAttributes(["area_m2"], parcels.fields())

for feature in parcels.getFeatures(request):
    ...

Breakdown: NoGeometry skips decoding geometry that an attribute-only update will never look at, which on a layer of complex polygons is most of the read time. setSubsetOfAttributes fetches only the columns named. Together they routinely halve the time of an attribute pass, and the details are covered in speeding up feature iteration with QgsFeatureRequest.

Constraints and why a commit fails

A commit that returns False is nearly always a constraint the provider enforced and the buffer did not. QGIS applies field constraints — not null, unique, an expression — at the buffer level only if they are declared on the layer; the underlying database applies its own regardless.

from qgis.core import QgsFieldConstraints

for field in parcels.fields():
    constraints = field.constraints().constraints()
    if constraints:
        print(field.name(),
              "not-null" if constraints & QgsFieldConstraints.ConstraintNotNull else "",
              "unique" if constraints & QgsFieldConstraints.ConstraintUnique else "",
              field.constraints().constraintExpression() or "")

Breakdown: Constraints are a bit field, so testing with & rather than equality is what catches a field carrying several. Printing them before a bulk update tells you which values will be rejected, and it is far cheaper than discovering it in commitErrors() after a long session. A constraint declared on the layer is checked as you edit and shows in the form; one that exists only in the database appears at commit and takes the whole session down with it, which is a strong argument for declaring them on the layer too.

The other frequent commit failure is a type mismatch — writing a Python str into an integer field, or a float into a field the provider declared as integer. The buffer accepts it, the provider rejects it, and the message names the field. Casting explicitly at the point of assignment removes the whole class of problem.

Transactions across several layers

For database-backed layers, QGIS can group edits so that several layers commit or roll back together — which is what you need when a change to a parcel and a change to its inspections must both succeed or both fail.

project = QgsProject.instance()
project.setTransactionMode(Qgis.TransactionMode.AutomaticGroups)

Breakdown: With automatic transaction groups enabled, starting an edit session on one PostGIS layer opens a database transaction shared by every layer from the same connection, and committing commits all of them. It only works for providers that support transactions — PostGIS does, GeoPackage does in recent releases, shapefile does not — and it changes the editing behaviour users see, so a plugin should not enable it behind their back. On 3.24 and earlier the equivalent is project.setAutoTransaction(True).

Where the layers do not share a connection, there is no cross-layer transaction and the honest approach is to order the writes so the least damaging one fails first, and to report clearly if a later one does.

Watching what the buffer holds

An edit session is inspectable, which is useful both for debugging and for reporting to a user before a commit.

print("added:", len(parcels.editBuffer().addedFeatures()))
print("changed attributes on:", len(parcels.editBuffer().changedAttributeValues()))
print("changed geometry on:", len(parcels.editBuffer().changedGeometries()))
print("deleted:", len(parcels.editBuffer().deletedFeatureIds()))

Breakdown: editBuffer() returns None when the layer is not in edit mode, so guard on isEditable() before calling any of these. The four collections are exactly what will be sent on commit, which makes them the right thing to summarise in a confirmation dialog — "this will update 412 parcels and delete 3" is far more useful than a bare "commit?". Added features carry negative ids until they are committed, which is worth knowing because any code correlating buffered features with stored ones has to account for the renumbering that happens at commit time.

The layer also emits signals for each of these — attributeValueChanged, geometryChanged, featureAdded, featureDeleted — which is how a panel stays in step with an ongoing edit session without polling. Connect to them with the same care about disconnecting described in object ownership and crashes, since an edit session outlives most of the code that starts it.

QGIS version compatibility

The edit buffer API has been stable across QGIS 3. QgsVectorLayer.setTransactionMode and Qgis.TransactionMode replaced setAutoTransaction in 3.26, with the older method retained. GeoPackage transaction support improved through 3.26 to 3.34. changeAttributeValues and changeGeometryValues on the provider have been present throughout.

Troubleshooting

  • startEditing() returns False. The source is read-only, or a file lock is held by another process.
  • commitChanges() returns False. Read commitErrors() — usually a not-null constraint or a type mismatch.
  • The layer stays in edit mode after an error. No rollback in the exception path.
  • Changes vanish on project close. They were staged and never committed.
  • A provider write had no effect. The field index was wrong; there is no error for writing to an index that does not exist on some providers.
  • Edits are extremely slow. A commit inside the loop, or geometry being fetched for an attribute-only pass.

Conclusion

Use the edit buffer whenever a person or a plugin is involved, wrap it in a try/except with a rollback, and commit once rather than per feature. Drop to the provider only for bulk work on data you can regenerate, and remember that it gives up undo entirely. On a database, transaction groups extend the same guarantee across related layers, which is what makes multi-layer edits safe.

Frequently Asked Questions

Can I undo a provider write? No. That is the trade — no buffer, no undo stack, no rollback. Work on a copy if the result might be wrong.

Does commitChanges keep the layer in edit mode? No by default; pass stopEditing=False to commit and continue editing, which is useful for checkpointing a long session.

How do I add features rather than change them?layer.addFeature(feature) inside an edit session, or provider.addFeatures([...]) for the direct path. Both need the feature's fields to match the layer's.

Why does my change not appear on the canvas? Call layer.triggerRepaint() after a provider write; the buffer path emits signals that refresh the canvas on its own.