Attribute Tables and Field Management in PyQGIS
Geometry gets the attention, but most automation work is attribute work: adding a calculated column, normalising a category, joining a spreadsheet of measurements onto a set of polygons, or exporting a summary for a report. PyQGIS gives you two quite different routes into the attribute table — the provider API, which writes straight to the data source, and the edit buffer, which stages changes so they can be undone — and knowing which one you are using is the difference between a script that is safe to re-run and one that corrupts a dataset halfway through.
This guide sits inside Spatial Data Processing & Automation. It covers the field model, adding and deleting fields, bulk value updates, attribute joins, aggregation, and export — with an emphasis on the transactional behaviour that decides whether a partial failure leaves your data intact.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- A writable vector layer. GeoPackage is strongly preferred over shapefile: shapefiles truncate field names to ten characters, cannot store
NULLin every type, and have no real date type. - Familiarity with the Python Console — see QGIS Python Console Basics.
- Optionally, an understanding of QGIS expressions, which are the shortest path to a calculated column.
The field model
A layer's schema is a QgsFields collection of QgsField objects. Each field has a name, a type, and — depending on the provider — a length and precision. The two things worth internalising are that field indexes are positional and shift when you delete a field, and that not every provider supports every operation.
from qgis.core import QgsProject, QgsVectorDataProvider
layer = QgsProject.instance().mapLayersByName("parcels")[0]
for field in layer.fields():
print(f"{field.name():<20} {field.typeName():<10} len={field.length()}")
capabilities = layer.dataProvider().capabilities()
print("can add fields:", bool(capabilities & QgsVectorDataProvider.AddAttributes))
print("can delete fields:", bool(capabilities & QgsVectorDataProvider.DeleteAttributes))
print("can change values:", bool(capabilities & QgsVectorDataProvider.ChangeAttributeValues))
Breakdown: Iterating layer.fields() yields QgsField objects in schema order. capabilities() returns a bit flag; testing it before attempting an edit turns a confusing silent failure into an explicit, early one. A read-only WFS layer or a delimited-text layer will fail these checks, and there is no point discovering that halfway through a loop.
Because indexes shift, always resolve a field by name at the moment you need it rather than caching a number across a schema change:
index = layer.fields().indexOf("assessed_value")
if index == -1:
raise KeyError("field 'assessed_value' does not exist")
Breakdown: indexOf() returns -1 rather than raising when the field is missing, which is easy to miss. Converting that into an explicit exception is nearly always what you want, because passing -1 to a change call silently does nothing.
Adding and removing fields
Adding a field goes through the provider, and the layer's own field cache has to be refreshed afterwards — the step that is most often forgotten, producing a script that adds a column and then insists it does not exist.
from qgis.core import QgsField, QgsProject
from qgis.PyQt.QtCore import QVariant
layer = QgsProject.instance().mapLayersByName("parcels")[0]
provider = layer.dataProvider()
if layer.fields().indexOf("value_per_m2") == -1:
provider.addAttributes([QgsField("value_per_m2", QVariant.Double, len=12, prec=2)])
layer.updateFields()
print(layer.fields().names())
Breakdown: QgsField takes a Qt type — QVariant.Double, QVariant.Int, QVariant.String, QVariant.Date. len and prec are honoured by providers that store them, and ignored by those that do not. updateFields() re-reads the schema into the layer object; without it, layer.fields() keeps returning the old list and every subsequent lookup by name fails. The existence check makes the script re-runnable, which matters in any batch processing pipeline.
Deleting is the mirror image, and this is where positional indexes bite: delete field 3 and every field after it moves down one. Resolve all the indexes first, then delete in descending order.
targets = ["temp_calc", "scratch"]
indexes = sorted(
(layer.fields().indexOf(name) for name in targets if layer.fields().indexOf(name) != -1),
reverse=True,
)
if indexes:
layer.dataProvider().deleteAttributes(indexes)
layer.updateFields()
Breakdown: Sorting descending means each deletion only affects indexes after the one being removed, which have already been handled. Filtering out -1 avoids passing a bogus index. Delete and Rename Fields in PyQGIS covers renaming, which has its own provider-support caveat.
Updating values in bulk
The naive update — start editing, loop, set each value, commit — works but is slow, because every call goes through the undo stack. For anything above a few thousand features, batch the changes into a single dictionary and hand them to the provider in one call.
from qgis.core import QgsProject
layer = QgsProject.instance().mapLayersByName("parcels")[0]
field_index = layer.fields().indexOf("value_per_m2")
updates = {}
for feature in layer.getFeatures():
area = feature.geometry().area()
value = feature["assessed_value"]
if area and value is not None:
updates[feature.id()] = {field_index: round(value / area, 2)}
layer.dataProvider().changeAttributeValues(updates)
layer.updateExtents()
Breakdown: The updates dictionary maps feature ID to a second dictionary of field index to new value, which is exactly the shape changeAttributeValues() expects. Building it first means one provider round-trip instead of n. The if area and value is not None guard skips features with no geometry or a NULL value rather than writing garbage — None propagating into arithmetic is the attribute-side equivalent of the NULL behaviour described in Working with QGIS Expressions.
When you do want undo support — in a plugin, where the user expects Ctrl+Z to work — use the edit buffer and make the commit path exception-safe:
layer.startEditing()
try:
for feature in layer.getFeatures():
layer.changeAttributeValue(feature.id(), field_index, 0)
if not layer.commitChanges():
raise RuntimeError("; ".join(layer.commitErrors()))
except Exception:
layer.rollBack()
raise
Breakdown: commitChanges() returns a bool rather than raising, so ignoring its result is how partial writes go unnoticed; commitErrors() explains what the provider rejected. The try/except guarantees the layer never stays stuck in edit mode after a failure, which would otherwise block every later operation on it. Update Attribute Values in Bulk with PyQGIS benchmarks the two approaches.
Calculating a field with an expression
Writing a Python loop to fill a column is fine, but when the logic is expressible as a QGIS expression there is a shorter and considerably faster route: native:fieldcalculator adds the field and computes it in a single Processing call, evaluated in C++ rather than per feature in Python.
import processing
result = processing.run("native:fieldcalculator", {
"INPUT": "/data/parcels.gpkg|layername=parcels",
"FIELD_NAME": "value_per_m2",
"FIELD_TYPE": 0, # 0 = float, 1 = integer, 2 = string, 3 = date
"FIELD_LENGTH": 12,
"FIELD_PRECISION": 2,
"FORMULA": 'coalesce("assessed_value", 0) / nullif($area, 0)',
"OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]
Breakdown: FIELD_TYPE is an integer code, not a QVariant type — a frequent copy-paste error when moving between the two APIs. The formula is a QGIS expression, so $area and coalesce() are available; nullif($area, 0) converts a zero area into NULL so the division yields NULL instead of raising, which is the expression engine's idiom for a guarded divide. Because the algorithm writes a new layer rather than editing in place, it is safe to re-run and easy to slot into a chained Processing pipeline.
The trade-off is control. The field calculator cannot carry state between features, call an external library, or branch on anything the expression engine does not model — the same boundary described in Working with QGIS Expressions. When you cross it, fall back to the batched provider update above.
Aliases, constraints and default values
A field is more than a name and a type. QGIS stores a layer of presentation and validation metadata on top of the schema, and setting it from a script is what turns a raw table into something a colleague can edit safely in the GUI.
from qgis.core import QgsDefaultValue, QgsFieldConstraints, QgsProject
layer = QgsProject.instance().mapLayersByName("parcels")[0]
index = layer.fields().indexOf("survey_date")
layer.setFieldAlias(index, "Date surveyed")
layer.setDefaultValueDefinition(index, QgsDefaultValue("now()", applyOnUpdate=False))
layer.setFieldConstraint(
index,
QgsFieldConstraints.ConstraintNotNull,
QgsFieldConstraints.ConstraintStrengthHard,
)
Breakdown: setFieldAlias() changes only the display label, leaving the underlying name — and therefore every expression referencing it — untouched. QgsDefaultValue takes an expression, not a literal, so now() is evaluated when a feature is created; applyOnUpdate=False means it stamps creation time rather than being rewritten on every edit. Constraints come in two strengths: a hard constraint blocks the commit, a soft one only warns. None of this is stored in the data source itself — it lives in the QGIS project or a companion .qml, so it travels with the project rather than the file.
Joining attributes between layers
An attribute join attaches the columns of one layer to another where a key matches. QGIS offers two flavours and they behave very differently.
import processing
joined = processing.run("native:joinattributestable", {
"INPUT": "/data/parcels.gpkg|layername=parcels",
"FIELD": "parcel_id",
"INPUT_2": "/data/valuations.csv",
"FIELD_2": "parcel_id",
"FIELDS_TO_COPY": ["assessed", "year"],
"METHOD": 1, # 1 = take attributes of the first matching feature only
"DISCARD_NONMATCHING": False,
"OUTPUT": "/data/output/parcels_valued.gpkg",
})["OUTPUT"]
Breakdown: METHOD decides what happens when the key matches more than one row — 1 keeps only the first match, 0 creates one output feature per match. DISCARD_NONMATCHING set to False keeps unmatched parcels with NULL in the joined columns, which is usually what you want for auditing; flipping it to True silently drops them. Writing to a GeoPackage produces a self-contained result with no dependency on the CSV. Join Attributes by Field Value in PyQGIS covers key-type mismatches, which cause the great majority of "the join returned all NULLs" reports.
Summarising by attribute
Counting features per category is the most common reporting task, and Python's collections.Counter combined with a targeted feature request handles it without loading geometry at all.
from collections import Counter
from qgis.core import QgsFeatureRequest, QgsProject
layer = QgsProject.instance().mapLayersByName("parcels")[0]
request = QgsFeatureRequest()
request.setFlags(QgsFeatureRequest.NoGeometry)
request.setSubsetOfAttributes(["zoning"], layer.fields())
counts = Counter(feature["zoning"] for feature in layer.getFeatures(request))
for zoning, count in counts.most_common():
print(f"{zoning or '(no value)':<16} {count}")
Breakdown: NoGeometry tells the provider not to read or decode geometry, which on a polygon layer is by far the biggest cost of iteration. setSubsetOfAttributes() narrows it further to the one column being counted. Together they turn a full-table read into something closer to a column scan. The or '(no value)' renders NULL readably instead of printing None. Count and Summarise Features by Attribute in PyQGIS extends this to sums and means, and to the native:statisticsbycategories algorithm when the result needs to be a layer.
Exporting the attribute table
Handing a table to someone who does not use QGIS means CSV, and QgsVectorFileWriter produces it with correct quoting and encoding — considerably safer than assembling the lines yourself.
from qgis.core import QgsCoordinateTransformContext, QgsProject, QgsVectorFileWriter
layer = QgsProject.instance().mapLayersByName("parcels")[0]
options = QgsVectorFileWriter.SaveVectorOptions()
options.driverName = "CSV"
options.fileEncoding = "UTF-8"
options.attributes = [layer.fields().indexOf(n) for n in ("parcel_id", "zoning", "assessed_value")]
options.layerOptions = ["GEOMETRY=AS_WKT"]
error, message, _, _ = QgsVectorFileWriter.writeAsVectorFormatV3(
layer, "/data/output/parcels.csv", QgsCoordinateTransformContext(), options
)
if error != QgsVectorFileWriter.NoError:
raise RuntimeError(message)
Breakdown: SaveVectorOptions collects the export settings; attributes restricts the columns by index, and omitting it exports everything. GEOMETRY=AS_WKT adds a WKT column — drop it for a pure attribute export. writeAsVectorFormatV3() returns a tuple whose first element is an error code, and checking it is essential because the function does not raise on failure. See Export an Attribute Table to CSV with PyQGIS for encoding pitfalls and the delimiter options.
Handling NULL, empty and missing
Three different absences look alike in a printed table and behave completely differently in code, and conflating them is the source of a surprising share of attribute bugs.
NULL means "no value recorded." PyQGIS surfaces it as Python None when reading through feature["field"]. Arithmetic involving it yields None, comparisons against it are false, and it is the only one of the three that survives a round-trip through GeoPackage and PostGIS faithfully.
An empty string is a value — the zero-length text "". A shapefile cannot distinguish it from NULL, which is why a dataset that round-trips through shapefile comes back with one where it had the other.
A missing field is a schema problem, not a data problem. feature["nonexistent"] raises KeyError, while layer.fields().indexOf("nonexistent") quietly returns -1.
from qgis.core import QgsProject
layer = QgsProject.instance().mapLayersByName("parcels")[0]
def read(feature, name, default=None):
"""Read a field defensively: missing field and NULL both collapse to a default."""
if layer.fields().indexOf(name) == -1:
return default
value = feature[name]
return default if value is None else value
for feature in layer.getFeatures():
owner = read(feature, "owner_name", "")
value = read(feature, "assessed_value", 0)
if not owner.strip():
print(f"parcel {feature['parcel_id']} has no owner recorded")
Breakdown: The helper separates the two failure modes but lets the caller treat them uniformly, which is usually what reporting code wants. Testing not owner.strip() catches NULL, the empty string, and a cell containing only whitespace in one expression — the third case being what you get from a hand-maintained spreadsheet. When writing values back, prefer an explicit None over an empty string so the distinction survives into GeoPackage.
A related gotcha: comparing to NULL in a filter expression needs IS NULL, not = NULL. The latter is valid syntax that is never true, so a QgsFeatureRequest filtered on "owner_name" = NULL returns nothing at all rather than the rows you meant.
Key takeaways
- Know which route you are on. Provider calls write immediately and cannot be undone; the edit buffer stages changes and must reach either
commitChanges()orrollBack(). - Call
updateFields()after a schema change. Without it the layer keeps serving a stale field list and lookups by name fail. - Delete fields in descending index order. Indexes are positional and shift as soon as one is removed.
- Batch bulk updates. One
changeAttributeValues()call with a full dictionary beats n individual edits, often by an order of magnitude. - Check
commitChanges(). It returnsFalserather than raising, andcommitErrors()is the only place the reason appears. - Prefer a materialised join for anything you hand on. A virtual join is a live link that breaks when the source file moves.
- Skip geometry when you only need attributes.
QgsFeatureRequest.NoGeometryplus a column subset is the cheapest way to read a table.
Frequently Asked Questions
Why does my new field not appear after addAttributes()?
The provider has it, but the layer object is still serving a cached schema. Call layer.updateFields() immediately after addAttributes() and the field will resolve by name from then on.
Should I use the data provider or startEditing() to change values?
Use the provider for scripted bulk work where speed matters and undo does not. Use startEditing() inside a plugin, where the user expects the change to appear on the undo stack and to be discardable. Never leave a layer in edit mode after an exception — wrap the block and call rollBack().
Why did my attribute join produce only NULL values?
Almost always a key-type mismatch: the parcels layer stores parcel_id as an integer while the CSV loads it as text, so nothing matches. Cast one side to the other's type before joining, or import the CSV with an explicit field-type definition.
How do I count features per category without loading geometry?
Build a QgsFeatureRequest, set the NoGeometry flag, restrict it with setSubsetOfAttributes() to the category column, then feed the iterator to collections.Counter. Geometry decoding is usually the dominant cost, so skipping it is the single biggest saving.
Is it safe to write attributes to a shapefile?
It works, but the format will surprise you: field names are truncated to ten characters, there is no true date type, and NULL support is inconsistent. Write to GeoPackage instead unless a downstream consumer specifically requires a shapefile.
Related
- Spatial Data Processing & Automation with PyQGIS — the guide this page belongs to
- Vector Data Manipulation in PyQGIS
- Batch Processing with PyQGIS
- Working with QGIS Expressions in PyQGIS
- Add a Field to a Layer in PyQGIS
- Update Attribute Values in Bulk with PyQGIS
- Delete and Rename Fields in PyQGIS
- Join Attributes by Field Value in PyQGIS
- Export an Attribute Table to CSV with PyQGIS
- Count and Summarise Features by Attribute in PyQGIS