Attribute Data and the Expression Engine in PyQGIS
Field schemas, QgsExpression and the expression context in PyQGIS: how to add fields, evaluate rules per feature, push filters into the provider, register…
Every non-trivial GIS automation eventually stops being about geometry and starts being about attributes: reclassifying a land-use code, deriving a density from two columns, selecting the subset of features a downstream process is allowed to touch. QGIS answers all three with the same machinery — a field schema on the layer and an expression engine that evaluates rules against it. This page, part of the PyQGIS Core Architecture & Data Handling guide, covers QgsField and schema changes, QgsExpression and the context that gives it meaning, the edit buffer that stands between a write and the disk, and the patterns that keep attribute work fast enough to run over millions of rows.
By the end you will know which of the four available write paths to use for a given job, why an expression that works in the field calculator returns NULL in your script, and how to make a per-feature rule run an order of magnitude faster without changing what it computes.
Prerequisites Checklist
- QGIS 3.28 LTR or newer.
QgsExpressionContextUtilsand the scope helpers used below are stable from 3.10 onward, but the examples assume the current LTR API. - Python 3.9+ for the type hints in every sample.
- A layer you can write to. GeoPackage is the easiest to experiment with; shapefile imposes a ten-character field-name limit that will confuse early experiments, as described in vector and raster data access patterns.
- Familiarity with the edit buffer. Attribute writes through the layer are buffered, and the buffer is the source of most surprises in this area.
- A test dataset with NULLs in it. Expression semantics around NULL are the single largest source of wrong results, and a dataset without any will hide every one of those bugs.
How the Expression Engine Works
A QGIS expression is not evaluated against a feature directly. It is evaluated against a QgsExpressionContext, which is an ordered stack of scopes — global, project, layer, and feature — each contributing variables and functions. When the evaluator meets a bare name like population, it walks that stack from the most specific scope outward until something resolves it. A field reference resolves in the feature scope; @project_folder resolves in the project scope; a custom function you registered resolves globally.
This design is what lets the same expression string work in the field calculator, in a symbology rule, in a Processing parameter and in your own code. It is also why an expression that works in the GUI returns nothing in a script: the GUI assembles a full context for you, and a bare QgsExpression("population > 1000") evaluated with no context has no feature scope, so population resolves to NULL and the comparison yields NULL rather than False.
Two calls turn this from a subtlety into a rule you can apply mechanically. setFields() or a layer scope tells the expression which field names exist; prepare(context) resolves each field name to a column index once, so the per-feature evaluation is an array lookup rather than a string comparison. Skipping prepare() is legal and produces correct results — it just does the same lookup work on every single row.
Step-by-Step Implementation
Step 1 — Inspect the schema before touching it
Field names are case-sensitive in some providers and folded in others, and a name that exists in your test file may not exist in production data. Resolve the index once, and treat -1 as a hard error rather than a value to pass on.
from qgis.core import QgsVectorLayer
def field_index(layer: QgsVectorLayer, name: str) -> int:
"""Return the index of `name` on `layer`, raising if it is absent.
QGIS returns -1 for an unknown field rather than raising, and -1 is a valid
argument to several attribute APIs, where it silently does nothing.
"""
idx = layer.fields().indexFromName(name)
if idx < 0:
available = ", ".join(f.name() for f in layer.fields())
raise KeyError("no field %r on %r (have: %s)" % (name, layer.name(), available))
return idx
Step 2 — Add a field through the provider
New columns are a schema change, so they go through the data provider rather than the edit buffer. The provider write is immediate and not undoable; call updateFields() afterwards so the layer’s cached schema matches what is now on disk.
from qgis.core import QgsField, QgsVectorLayer
from qgis.PyQt.QtCore import QVariant
def add_double_field(layer: QgsVectorLayer, name: str) -> int:
"""Add a double-precision field to `layer` and return its index."""
if layer.fields().indexFromName(name) >= 0:
return layer.fields().indexFromName(name) # already present, nothing to do
provider = layer.dataProvider()
if not provider.addAttributes([QgsField(name, QVariant.Double)]):
raise RuntimeError("provider refused to add %r" % name)
layer.updateFields()
return field_index(layer, name)
Step 3 — Build and prepare an expression
Construct the expression once, hand it a context that carries the layer scope, and prepare it. QgsExpression never raises on a bad expression: it records an error you have to ask for.
from qgis.core import (QgsExpression, QgsExpressionContext,
QgsExpressionContextUtils, QgsVectorLayer)
def prepared_expression(layer: QgsVectorLayer, text: str):
"""Return a parsed, prepared expression and the context to evaluate it with."""
expression = QgsExpression(text)
if expression.hasParserError():
raise ValueError("cannot parse %r: %s" % (text, expression.parserErrorString()))
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
expression.prepare(context)
return expression, context
Step 4 — Evaluate per feature and write the result
The write goes through changeAttributeValue() inside an edit session, so it lands on the undo stack and can be rolled back as a unit. Check the evaluation error on every row: an expression that divides by a NULL column produces an error for that feature only, and silently writing the resulting NULL is how bad data spreads.
from qgis.core import QgsVectorLayer
def calculate_field(layer: QgsVectorLayer, target: str, text: str) -> int:
"""Evaluate `text` for every feature and write it to `target`. Returns rows written."""
target_idx = add_double_field(layer, target)
expression, context = prepared_expression(layer, text)
written = 0
layer.startEditing()
try:
for feature in layer.getFeatures():
context.setFeature(feature)
value = expression.evaluate(context)
if expression.hasEvalError():
raise RuntimeError("feature %s: %s" % (feature.id(), expression.evalErrorString()))
if layer.changeAttributeValue(feature.id(), target_idx, value):
written += 1
if not layer.commitChanges():
raise RuntimeError("commit failed: %s" % "; ".join(layer.commitErrors()))
except Exception:
layer.rollBack()
raise
return written
The try block matters more than it looks. Without the rollback, an exception halfway through leaves the layer in an open edit session with partial changes buffered — and the next piece of code to call startEditing() inherits them.
Advanced Patterns
Push the rule into the provider when it only selects
If the expression decides which rows to process rather than computing a value, it does not need to run in Python at all. QgsFeatureRequest.setFilterExpression() hands the same expression text to the provider, which compiles what it can into its native query language — a SQL WHERE clause on PostGIS, a SQLite predicate on GeoPackage — and returns only matching features.
from qgis.core import QgsFeatureRequest, QgsVectorLayer
def active_large_parcels(layer: QgsVectorLayer):
"""Yield parcels the provider itself selected, never materialising the rest."""
request = (QgsFeatureRequest()
.setFilterExpression("status = 'ACTIVE' AND area_m2 > 1000")
.setSubsetOfAttributes(["parcel_id", "area_m2"], layer.fields()))
yield from layer.getFeatures(request)
What the provider cannot compile it evaluates itself, feature by feature, after reading the row — so the call is always correct and sometimes free. Expressions using QGIS-specific functions ($area, @atlas_feature) always fall into the second category, which is a good reason to prefer a stored area_m2 column over $area in any expression that runs at scale.
Register a custom function once, use it everywhere
The @qgsfunction decorator adds a function to the global expression scope. Once registered it is available to the field calculator, symbology, labelling and your own code alike, which makes it a good home for a domain rule that would otherwise be copied into a dozen expression strings.
from qgis.core import qgsfunction
@qgsfunction(args="auto", group="Custom", referenced_columns=[])
def band_for_density(density, feature, parent):
"""Return the planning density band for a value in dwellings per hectare."""
if density is None:
return None
if density < 20:
return "low"
return "medium" if density < 60 else "high"
Declaring referenced_columns=[] matters for performance: it tells QGIS the function does not read fields directly, so the engine does not have to fetch every attribute to evaluate it.
Virtual fields for values that must stay derived
A virtual field stores the expression, not the value, and recomputes it whenever the column is read. That is exactly right for a value which must never drift from its inputs — a ratio of two columns that other processes keep updating — and exactly wrong for anything expensive, because the cost is paid on every read, including every repaint of the attribute table.
Bulk writes that skip the buffer
For a one-way batch job with no undo requirement, provider.changeAttributeValues() takes a dictionary of feature id to attribute-map and writes it in one call. It bypasses the edit buffer entirely, which makes it substantially faster over large datasets and completely unrecoverable if it goes wrong. Use it in pipelines that can re-run from source, not in plugins where a user expects Ctrl+Z to work.
Pitfalls and Debugging
-
NULL is not False. In QGIS expression semantics, comparing anything with NULL yields NULL, and a filter that evaluates to NULL excludes the row. A rule like
status != 'CLOSED'therefore silently drops every feature whose status is unset. Writestatus IS NULL OR status != 'CLOSED'when unset should count. -
hasEvalError()is per evaluation, not per expression. It reflects the most recentevaluate()call. Checking it once after the loop tells you only about the last feature. -
Changing a field on a layer being iterated. Writing attributes inside a
getFeatures()loop on the same layer is undefined behaviour on some providers. Collect the changes into a dictionary and apply them after the loop, or iterate a separate request. -
updateFields()forgotten after a schema change. The provider has the new column but the layer’s cachedfields()does not, soindexFromName()returns -1 for a field that visibly exists in the file. -
Shapefile field names silently truncated. The DBF format allows ten characters. A field called
population_densitybecomespopulatio, and every expression referencing the full name resolves to NULL with no error at all. -
A commit that returns False.
commitChanges()reports failure through its return value and fillscommitErrors(). Ignoring it produces a script that logs success and changes nothing — most often because a constraint or a read-only provider rejected the write.
Frequently Asked Questions
Why does my expression return NULL for every feature?
Because the context has no feature scope, or no layer scope, so the field name never resolves. An expression is evaluated against a context, not against a layer, and a default-constructed QgsExpressionContext is empty.
Build the context with QgsExpressionContextUtils.globalProjectLayerScopes(layer) and call context.setFeature(feature) before each evaluation. If the field name is spelled differently from what the provider actually stores — a truncated shapefile name, or a case difference — the reference will still resolve to NULL, so check layer.fields().names() when the context looks right.
Do I have to call prepare()?
No, but it is close to free and it is worth an order of magnitude on large layers. prepare() resolves every field reference in the parsed tree to a column index once. Without it, each evaluation looks each name up by string.
The one requirement is that the context you prepare with must have the same fields as the contexts you later evaluate with. Preparing against one layer and evaluating against another with a different schema produces wrong values rather than an error.
What is the difference between a virtual field and a calculated field?
A calculated field is an ordinary column: the expression runs once, the values are written to disk, and they stay as they are until something recalculates them. A virtual field stores the expression instead, and recomputes the value every time the column is read.
Use a calculated field for anything expensive or anything that must be reproducible later; use a virtual field when the value must never fall out of step with the columns it derives from. Virtual fields are also not written to most output formats, so a job that exports its result needs a real column.
Can expressions be used from a background thread?
Evaluation itself is fine on a worker thread as long as the context does not reach back into main-thread objects. The trap is the layer scope: it holds a reference to the layer, and touching a QgsVectorLayer from a worker is not safe.
For threaded work, build the context and prepare the expression on the main thread, then evaluate against features you pre-fetched. The asynchronous task execution guide covers the wider rule this is one instance of.
How do I test an expression without a layer?
Construct a QgsFeature with an explicit QgsFields schema, set attributes on it, and evaluate against a context whose feature scope you set yourself. No project, no provider and no file are required, which makes expression rules straightforward to unit-test with pytest-qgis.
Conclusion
Attribute work in PyQGIS is governed by two ideas that repay learning properly: expressions are evaluated against a context rather than a feature, and writes go either through the buffered, undoable layer API or the immediate, unrecoverable provider API. Get the context right and expressions behave identically in your script and in the field calculator. Choose the write path deliberately and you get either an undo stack or throughput, rather than discovering which one you have after a failed run.
Related
- PyQGIS Core Architecture & Data Handling — the parent guide covering the execution model this page sits inside
- Vector and Raster Data Access Patterns — how requests reach the provider, and what it can push down
- Working with QgsProject and the Layer Registry — resolving the layers these expressions run against
- Layer Styling and Symbology Automation — where the same expression engine drives rendering
- Spatial Indexing and Query Optimization — combining attribute filters with spatial ones