Undo Edits with the Edit Buffer in PyQGIS
When a vector layer is in edit mode, changes do not go to the data source straight away. They collect in the layer's edit buffer: added features, deleted ids, changed attributes and changed geometries, each recorded on an undo stack. The data source sees them only when the edits are committed. This is what lets a person digitize for an hour, undo the last three steps, and then save — and it is what a plugin should use whenever its changes happen alongside the user's own.
This recipe belongs to Features, Geometries & Memory Layers. It drives the edit buffer from Python: opening and closing edit sessions, grouping a script's changes into one undo step, looking at pending changes before they are saved, and handling a commit that fails.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- An editable layer — check that
layer.dataProvider().capabilities()includes the capability you need (ChangeAttributeValues,ChangeGeometries,DeleteFeatures). - Code running on the main thread. The edit buffer is not thread-safe; background work should compute changes and apply them on the main thread.
Start, commit and roll back
The three basic calls map directly to the buttons in the Digitizing toolbar. Each returns a boolean, and each should be checked.
from qgis.core import QgsProject
pipes = QgsProject.instance().mapLayersByName("water_pipes")[0]
status_idx = pipes.fields().indexOf("status")
if not pipes.isEditable() and not pipes.startEditing():
raise RuntimeError("layer cannot be edited")
for f in pipes.getFeatures('"install_year" < 1960'):
pipes.changeAttributeValue(f.id(), status_idx, "review")
print(pipes.isModified(), "pending changes")
if not pipes.commitChanges():
print("\n".join(pipes.commitErrors()))
pipes.rollBack()
Breakdown: Checking isEditable() first avoids interfering with an edit session the user already started — calling startEditing() on a layer that is already editable simply returns False, which a careless script treats as failure. isModified() reports whether the buffer holds anything. commitChanges() writes everything in one provider call per change type and, on success, clears the buffer and leaves edit mode. On failure it returns False and keeps the layer editable with the buffer intact, so the script must decide between showing the errors to a user and rolling back.
For a script that should not leave a layer half-edited under any circumstances, the edit() context manager does all of this: it starts editing, commits at the end of the block, and rolls back if an exception escapes — the pattern used in adding features to a vector layer.
Group a script's changes into one undo step
Without grouping, each changeAttributeValue becomes its own entry on the undo stack. A plugin that updates four hundred features leaves four hundred entries, and the user has to press Ctrl+Z four hundred times. Edit commands wrap a batch of changes into a single, named step.
pipes.startEditing()
pipes.beginEditCommand("Mark old pipes for review")
try:
for f in pipes.getFeatures('"install_year" < 1960'):
pipes.changeAttributeValue(f.id(), status_idx, "review")
except Exception:
pipes.destroyEditCommand() # undo the partial batch
raise
else:
pipes.endEditCommand()
stack = pipes.undoStack()
print(stack.count(), "undo entries; top:", stack.text(stack.index() - 1))
Breakdown: beginEditCommand(text) opens a macro on the layer's undo stack; every change until endEditCommand() joins it. The text appears in QGIS's Undo/Redo panel, so make it describe the action in the user's terms. destroyEditCommand() reverts the changes made since the command began and discards the command — the right response to an error halfway through, because it leaves the buffer exactly as it was before your code ran. The layer stays in edit mode afterwards, letting the user inspect the result and save or undo it with the normal tools.
Inspect pending changes before saving
The edit buffer is readable. A plugin can show a summary — "12 new, 3 deleted, 40 changed" — or validate pending changes before committing them.
buf = pipes.editBuffer()
if buf is None:
print("layer is not in edit mode")
else:
added = buf.addedFeatures() # {temp_id: QgsFeature}
deleted = buf.deletedFeatureIds() # [fid, ...]
changed_attrs = buf.changedAttributeValues() # {fid: {field_idx: value}}
changed_geoms = buf.changedGeometries() # {fid: QgsGeometry}
print(f"{len(added)} new, {len(deleted)} deleted, "
f"{len(changed_attrs)} with attribute edits, {len(changed_geoms)} reshaped")
for fid, changes in list(changed_attrs.items())[:5]:
old = pipes.dataProvider().getFeatures(
QgsFeatureRequest().setFilterFid(fid)).__next__()
for idx, new in changes.items():
print(fid, pipes.fields()[idx].name(), old[idx], "→", new)
Breakdown: editBuffer() returns None outside edit mode. Its four accessors give everything pending, keyed by feature id; added features carry negative temporary ids. Reading the original value needs the data provider rather than the layer, because layer.getFeatures() returns features with the buffer's changes already applied — which is exactly what a user sees in the attribute table, and exactly what you do not want when building a before-and-after report. Add from qgis.core import QgsFeatureRequest if you run the snippet on its own.
Handle a failed commit
Commits fail for reasons the buffer could not foresee: a database constraint, a lock held by another user, a dropped connection, a feature deleted by someone else since it was read. commitErrors() returns a list of human-readable messages.
from qgis.core import Qgis, QgsMessageLog
def commit_or_report(layer, interactive, iface=None):
if layer.commitChanges():
return True
errors = layer.commitErrors()
for line in errors:
QgsMessageLog.logMessage(line, "MyPlugin", Qgis.Critical)
if interactive and iface is not None:
iface.messageBar().pushCritical("Save failed", errors[-1] if errors else "unknown error")
# leave the layer editable so the user can fix and retry
else:
layer.rollBack()
return False
Breakdown: The last line of commitErrors() is usually the provider's own message — the database error text — and is the most useful one to show. In a plugin, leaving the layer in edit mode after a failure respects the user's work: their other edits are still in the buffer. In an unattended script, rolling back keeps the data source and the project in a known state, and the logged errors explain why the run made no changes. Logging messages to the QGIS message log covers channels and levels.
Edit several layers together
Changes that span layers — a parcel split that also updates an owner table — should succeed or fail together. With transaction groups enabled on the project, layers from the same database share one transaction, and committing one commits all.
from qgis.core import Qgis, QgsProject
project = QgsProject.instance()
project.setTransactionMode(Qgis.TransactionMode.AutomaticGroups)
# reload layers or reopen the project for the mode to apply to them
parcels = project.mapLayersByName("parcels")[0]
owners = project.mapLayersByName("owners")[0]
parcels.startEditing() # also puts owners into edit mode
# ... changes to both layers ...
parcels.commitChanges() # one database COMMIT for both
Breakdown: In automatic transaction groups, layers from the same PostgreSQL or GeoPackage connection join a single database transaction when editing starts, and edits are sent to the database immediately inside that transaction rather than held in the buffer. Commit and rollback apply to the whole group. The mode is a project property and takes effect for layers loaded after it is set. The trade-offs, including locking, are covered in editing features with transactions.
React to edits with layer signals
Plugins often need to respond to edits rather than make them: recompute a derived field when a geometry changes, refuse a commit that breaks a business rule, or refresh a dock widget after a save. The layer emits signals at each stage of the buffer's life, and connecting to them is how a plugin stays in step with the user's editing.
def on_geometry_changed(fid, geom):
length_idx = pipes.fields().indexOf("length_m")
pipes.changeAttributeValue(fid, length_idx, round(geom.length(), 2))
def on_before_commit():
buf = pipes.editBuffer()
for fid, f in buf.addedFeatures().items():
if not f["material"]:
iface.messageBar().pushWarning("Pipes", "New pipe without material")
def on_after_commit():
print("saved; refreshing summary panel")
pipes.geometryChanged.connect(on_geometry_changed)
pipes.beforeCommitChanges.connect(on_before_commit)
pipes.afterCommitChanges.connect(on_after_commit)
Breakdown: geometryChanged fires for every reshaped feature while editing, so the derived length_m stays current inside the same edit session — and it goes onto the undo stack together with the user's own change when the edit happens inside a tool's edit command. beforeCommitChanges is the last moment to inspect the buffer; it cannot veto the commit by itself, so warn here or validate with field constraints instead of relying on it as a gate. afterCommitChanges fires only on a successful save, which makes it the right hook for refreshing anything that reads from the data source. Disconnect these handlers in the plugin's unload(), as described in connecting layer signals, or they keep firing after the plugin is reloaded.
QGIS version compatibility
The edit buffer API — startEditing, commitChanges, rollBack, edit commands and editBuffer() accessors — is unchanged from QGIS 3.0 to QGIS 4. Qgis.TransactionMode replaced the boolean setAutoTransaction in 3.26; use the enum on all current releases. commitChanges(stopEditing=False) keeps the layer in edit mode after a successful save, available since 3.10.
Troubleshooting
startEditing()returns False. The layer is already editable, or the provider is read-only; checkisEditable()and the capabilities.- Changes appear in the table but not in the file. The layer is still in edit mode; nothing is written until commit.
- Undo removes only one feature of my batch. The changes were not wrapped in an edit command.
- QGIS crashes when a background task edits. The edit buffer must only be touched on the main thread; return results from the task and apply them in
finished().
Conclusion
Respect an existing edit session, wrap a script's changes in a named edit command so one undo reverses them, use destroyEditCommand to back out a failed batch, read the buffer to summarise or validate pending changes, and treat commitErrors() as the explanation to log or show when a save fails.
Frequently Asked Questions
Does writing through the data provider use the undo stack? No. Provider writes bypass the buffer entirely and cannot be undone from QGIS.
Can I commit without leaving edit mode?
Yes: layer.commitChanges(stopEditing=False).
How do I trigger undo from code?layer.undoStack().undo(), the same action as Ctrl+Z.
Are memory layers edited the same way? Yes. The buffer works identically; committing writes into the memory provider.