Update Attribute Values in Bulk with PyQGIS

Writing a value to one feature is trivial. Writing values to fifty thousand features is where the two PyQGIS editing routes stop being interchangeable: the provider route sends one batched instruction and finishes in seconds, while the edit-buffer route sends fifty thousand individually and pushes each one onto an undo stack. Both are correct; picking the wrong one for the context turns a five-second job into a five-minute one, or silently loses an entire batch when a commit fails unchecked.

This page is a focused recipe within Attribute Tables and Field Management in PyQGIS. It covers the batched provider update, the transactional edit-buffer form, updating a filtered subset, and the failure handling that keeps a layer usable when something goes wrong.

One instruction versus fifty thousandThe upper path shows a Python loop building a single dictionary of all changes, which is handed to changeAttributeValues as one provider call. The lower path shows each change being pushed individually through the edit buffer and undo stack before a final commit, with many more arrows crossing the boundary to the data source.Same result, very different number of round-tripsbuild a dictionary{fid: {idx: value}}all in memory, no I/OchangeAttributeValuesone callno undoloop the featureschangeAttributeValueonce per featureedit bufferundo stack growscommitChanges() at the endthe data sourceidentical result

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A writable vector layer with the target field already present — see Add a Field to a Layer in PyQGIS if it is not.
  • A backup, or a GeoPackage you can afford to lose. The provider route has no undo.

The batched provider update

Build the whole change set in memory, then send it once.

from qgis.core import QgsProject

layer = QgsProject.instance().mapLayersByName("parcels")[0]
index = layer.fields().indexOf("rate_band")
if index == -1:
    raise KeyError("field 'rate_band' does not exist")

updates = {}
for feature in layer.getFeatures():
    value = feature["assessed_value"]
    if value is None:
        continue
    band = "A" if value > 1_000_000 else "B" if value > 400_000 else "C"
    updates[feature.id()] = {index: band}

ok = layer.dataProvider().changeAttributeValues(updates)
print("wrote", len(updates), "row(s):", ok)

Breakdown: The dictionary maps feature ID to a dictionary of field index to new value — one entry per feature, and a feature can carry several field changes in its inner dictionary. Resolving the index once outside the loop avoids repeating a name lookup fifty thousand times. changeAttributeValues() returns a bool: False means the provider rejected the batch, and checking it is the only way to notice.

Note that continue on a NULL leaves that feature's band untouched rather than writing None. That is a deliberate choice worth making consciously — the alternative, updates[feature.id()] = {index: None}, explicitly blanks it.

Update only what matched

Reading every feature to change a few is wasteful. Push the selection into the request and skip geometry entirely if the rule does not need it.

from qgis.core import QgsFeatureRequest, QgsProject

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

request = QgsFeatureRequest()
request.setFilterExpression('"status" IS NULL AND "assessed_value" > 0')
request.setFlags(QgsFeatureRequest.NoGeometry)
request.setSubsetOfAttributes(["assessed_value"], layer.fields())

updates = {f.id(): {index: "active"} for f in layer.getFeatures(request)}
layer.dataProvider().changeAttributeValues(updates)
print(len(updates), "row(s) back-filled")

Breakdown: setFilterExpression() is translated into SQL on providers that support it, so a GeoPackage or PostGIS layer never sends the non-matching rows at all. NoGeometry skips geometry decoding, which on a polygon layer is usually the dominant cost of iteration. setSubsetOfAttributes() narrows the columns further. Note IS NULL rather than = NULL — the latter is valid syntax that is never true, and returns zero rows silently.

The transactional route

Inside a plugin, the user expects Ctrl+Z to work and expects a failure to leave the data unchanged. That means the edit buffer, and it means handling the commit properly.

Every edit session must reach one of two exitsstartEditing opens an edit buffer. Changes accumulate in memory. From there, commitChanges either succeeds and writes to the source, or returns false, in which case commitErrors explains why and rollBack discards the session. An exception raised mid-loop takes the same rollback path. A layer left in edit mode blocks later operations.Leaving a layer in edit mode blocks everything that followsstartEditing()buffer openschanges accumulatein memory, undoablecommitChanges() → Truewritten to the source→ FalsecommitErrors() says whyexception raisedmid-looprollBack()layer usable again

from qgis.core import QgsProject

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

if not layer.startEditing():
    raise RuntimeError("could not start an edit session")

try:
    for feature in layer.getFeatures():
        layer.changeAttributeValue(feature.id(), index, "reviewed")
    if not layer.commitChanges():
        raise RuntimeError("; ".join(layer.commitErrors()))
except Exception:
    layer.rollBack()
    raise

Breakdown: startEditing() returns False on a read-only layer, so checking it fails early rather than mid-loop. changeAttributeValue() — singular — stages one change on the buffer. commitChanges() returns a bool rather than raising, and commitErrors() is the only place the provider's reason appears, so raising with that text attached is what makes the failure diagnosable. The except clause guarantees rollBack() runs on any failure; without it the layer stays in edit mode and every later operation on it fails with an unrelated-looking error.

Chunk very large updates

A single dictionary holding a million entries is fine for memory but gives you nothing to report and nothing to resume from if the write fails halfway. Chunking turns one opaque operation into a progress-reporting, restartable one.

One write versus chunked writesThe upper bar shows a single write covering a million rows with no progress information, where a failure loses everything. The lower bar shows the same work split into five chunks, each reporting progress on completion, so a failure in the fourth chunk leaves the first three already written.Chunking buys progress and a restart pointone write1 000 000 rows — silent until it finishes or failschunked200k ✓400k ✓600k ✓fails herenot attempted600 000 rows already written — restart from here

from itertools import islice
from qgis.core import QgsProject

layer = QgsProject.instance().mapLayersByName("parcels")[0]
index = layer.fields().indexOf("rate_band")
provider = layer.dataProvider()

CHUNK = 50_000


def band(value):
    return "A" if value > 1_000_000 else "B" if value > 400_000 else "C"


rows = (
    (f.id(), {index: band(f["assessed_value"])})
    for f in layer.getFeatures()
    if f["assessed_value"] is not None
)

written = 0
while True:
    batch = dict(islice(rows, CHUNK))
    if not batch:
        break
    if not provider.changeAttributeValues(batch):
        raise RuntimeError(f"write failed after {written} row(s)")
    written += len(batch)
    print(f"{written:,} written")

Breakdown: rows is a generator, so no feature is materialised until islice pulls it — memory stays flat regardless of layer size. islice(rows, CHUNK) takes the next slice from the same generator each time round the loop, which is what makes the pattern work without an index variable. Reporting written in the exception message tells you exactly where to resume. A chunk size of fifty thousand is a reasonable default; smaller chunks give finer progress at the cost of more round-trips.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.28 LTR3.9Identical API for both routes.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12QgsFeatureRequest.Flag.NoGeometry is the newer spelling; the old constant still resolves.

changeAttributeValues(), startEditing(), commitChanges() and rollBack() are unchanged across 3.x.

Troubleshooting

  • Nothing was written and no error appeared. The field index was -1. Resolve it with indexOf() and raise if it is negative.
  • changeAttributeValues() returned False. The provider rejected the batch — often a type mismatch, such as writing a string into a numeric column, or a constraint violation.
  • The layer is stuck and every later call fails. It is still in edit mode from a failed run. Call layer.rollBack(), and wrap future edit sessions so it cannot happen again.
  • A filter matched nothing. = NULL was used where IS NULL was meant. The former is valid and never true.
  • The update is very slow. Per-feature edits through the buffer. Batch them into one changeAttributeValues() call unless undo support is genuinely required.
  • Values look right in the console but not on the map. The canvas is showing a cached render. Call layer.triggerRepaint().

Conclusion

For scripted bulk work, build the entire change set as a {fid: {index: value}} dictionary and send it in one changeAttributeValues() call, checking the return value. For anything a user will interact with, use the edit buffer and make sure every session reaches either a verified commit or a rollback — a layer abandoned in edit mode is a problem that surfaces far away from its cause.

Frequently Asked Questions

Which is faster, the provider or the edit buffer? The provider, substantially. One batched changeAttributeValues() call is a single round-trip; the edit buffer pushes every change onto an undo stack individually. Use the buffer only when undo support matters.

Why did my update silently do nothing? Almost always a field index of -1, which changeAttributeValues() accepts and ignores. Resolve the index with indexOf() and raise explicitly when it is negative.

How do I know why a commit failed?commitChanges() returns False rather than raising. layer.commitErrors() returns the list of provider messages explaining what was rejected — include it in the exception you raise.

Does changeAttributeValues() validate the values I pass? Only loosely. The provider rejects a value it cannot store — text into a numeric column, for example — and returns False for the whole batch, but it does not enforce field constraints or default-value expressions configured in the project. Those live in the QGIS layer rather than the data source, so a provider write bypasses them entirely. If constraints matter, go through the edit buffer, where commitChanges() applies them and reports violations through commitErrors().

How do I roll back an update I already committed? You cannot, through either route — a commit is final and the provider route never had an undo. The practical protections are to work on a copy, to write to a new layer with native:fieldcalculator rather than editing in place, or to snapshot the original values into a spare column before overwriting them. On a GeoPackage, copying the file before a bulk update takes a second and has saved many afternoons.

Can I update only the selected features? Yes. Iterate layer.getSelectedFeatures() instead of getFeatures(), or build a request filtered by layer.selectedFeatureIds(). See Select Features by Expression in PyQGIS.

Does changing a value trigger a repaint? Not automatically. Call layer.triggerRepaint() when the changed field drives symbology or labelling, or the map keeps drawing from its cached render.