How to Run Field Calculator Expressions from PyQGIS
On this page
Problem statement
In the QGIS GUI, the Field Calculator does this in ten seconds:
round($area / 10000, 2)
concat("street", ' ', "town")
CASE WHEN "pop" > 5000 THEN 'large' ELSE 'small' END
From a script, people usually rewrite those in Python, loop over features, and lose all the things the expression engine gave them for free: $area in the project's units, geometry functions, aggregates, variables, and null handling that matches what the GUI produced.
You want the expression engine itself, because:
- the expressions already exist β in a project, a style, a model or a colleague's notes
- results must match what the GUI produced, exactly
- expressions handle nulls, types and geometry in QGIS's own way
- the engine can aggregate across layers, including spatially, inside one expression
- a virtual field re-evaluates on the fly, with no data written at all
The examples run under QGIS 4.2 on HM Land Registry's 21,253 freehold parcels in Rutland and the county's 15 ONS wards.
Quick answer
Use QgsExpression with a QgsExpressionContext, or run the native:fieldcalculator algorithm:
- add the target field to the layer (or let the algorithm create it)
- build a
QgsExpressionand a context that knows about the project and the layer - evaluate per feature, or use the Processing algorithm for a whole layer
- always check
expression.hasParserError()before evaluating - write the changes, and verify the values
from qgis.core import (
QgsVectorLayer, QgsExpression, QgsExpressionContext,
QgsExpressionContextUtils, QgsField,
)
from qgis.PyQt.QtCore import QMetaType
layer = parcels_copy("quick")
assert layer.isValid()
expr = QgsExpression("round($area / 10000, 3)")
if expr.hasParserError():
raise SystemExit(f"bad expression: {expr.parserErrorString()}")
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
layer.startEditing()
layer.addAttribute(QgsField("area_ha", QMetaType.Type.Double))
layer.updateFields()
idx = layer.fields().indexOf("area_ha")
expr.prepare(context)
for feature in layer.getFeatures():
context.setFeature(feature)
value = expr.evaluate(context)
if expr.hasEvalError():
print(f"feature {feature.id()}: {expr.evalErrorString()}")
continue
layer.changeAttributeValue(feature.id(), idx, value)
layer.commitChanges()
largest = max(layer.getFeatures(), key=lambda f: f["area_ha"])
print(f"updated {layer.featureCount():,} features; largest {largest['INSPIREID']} at {largest['area_ha']} ha")
updated 21,253 features; largest 31377229 at 714.983 ha
expr.prepare(context) resolves field references against the context once, before the loop; hasParserError() makes a typo fail before the first feature rather than after the last.
The anatomy of an expression evaluation
$area and variables are unknown.Parse, give it context, then update efficiently
Parse before you evaluate
from qgis.core import QgsExpression
expr = QgsExpression('concat("INSPIREID", \' \', "WD24NM")')
if expr.hasParserError():
raise ValueError(f"{expr.expression()}: {expr.parserErrorString()}")
print("referenced columns:", expr.referencedColumns())
print("needs geometry :", expr.needsGeometry(), "| $area needs it:", QgsExpression("$area").needsGeometry())
broken = QgsExpression('round($area / 10000, 3')
print("parser error :", broken.hasParserError(), "β", broken.parserErrorString())
referenced columns: {'WD24NM', 'INSPIREID'}
needs geometry : False | $area needs it: True
parser error : True β
Incomplete expression. You might not have finished the full expression., expecting COMMA or ')'
referencedColumns() is useful in a pipeline: it tells you which fields must exist before the expression can run, so you can validate the schema up front rather than discovering a missing column halfway through.
Build a context with the right scopes
An expression context is a stack of scopes β global, project, layer, feature β each providing variables and functions. What the scopes change is easy to underestimate. Here is $area for one parcel, evaluated on the British National Grid layer and on the same layer reprojected to WGS 84, with and without the scopes:
import processing
from qgis.core import QgsDistanceArea, QgsProject
bng = parcels_copy("scopes")
wgs84 = processing.run("native:reprojectlayer", {"INPUT": bng, "TARGET_CRS": "EPSG:4326",
"OUTPUT": "TEMPORARY_OUTPUT"})["OUTPUT"]
def area_of(layer, with_scopes, calculator=None):
feature = next(layer.getFeatures())
context = QgsExpressionContext()
if with_scopes:
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
context.setFeature(feature)
expression = QgsExpression("$area")
if calculator:
expression.setGeomCalculator(calculator)
return expression.evaluate(context)
ellipsoidal = QgsDistanceArea()
ellipsoidal.setSourceCrs(wgs84.crs(), QgsProject.instance().transformContext())
ellipsoidal.setEllipsoid("EPSG:7030") # WGS 84
print(f"BNG layer, no scopes: {area_of(bng, False):14,.1f}")
print(f"BNG layer, with scopes: {area_of(bng, True):14,.1f}")
print(f"WGS84 layer, no scopes: {area_of(wgs84, False):14.3e}")
print(f"WGS84 layer, with scopes: {area_of(wgs84, True):14,.1f}")
print(f"WGS84 layer, ellipsoidal calc: {area_of(wgs84, True, ellipsoidal):14,.1f}")
BNG layer, no scopes: 321,109.1
BNG layer, with scopes: 321,109.1
WGS84 layer, no scopes: 4.260e-05
WGS84 layer, with scopes: 527,868.7
WGS84 layer, ellipsoidal calc: 321,276.2
On the projected layer the scopes make no difference. On the WGS 84 layer, without them $area is in square degrees; with them the project scope converts it to the project's area unit, square metres β but with no ellipsoid set on the project the conversion treats a degree of longitude as if it were at the equator, and at Rutland's latitude the answer is 64% too large. Only an ellipsoidal calculator gives the real area. Set one (or an ellipsoid on the project) whenever the layer is geographic.
Project variables work the same way:
QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "batch_date", "2026-09-30")
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(bng))
context.setFeature(next(bng.getFeatures()))
print(QgsExpression("concat(\"INSPIREID\", '-', @batch_date)").evaluate(context))
32383481-2026-09-30
Use the Processing algorithm for whole-layer work
For a straightforward calculation over a whole layer, the algorithm is less code and handles the writing for you. FIELD_TYPE is an index into this list β worth printing, because the numbers are not what most snippets assume:
alg = QgsApplication.processingRegistry().algorithmById("native:fieldcalculator")
print(dict(enumerate(alg.parameterDefinition("FIELD_TYPE").options())))
result = processing.run("native:fieldcalculator", {
"INPUT": bng,
"FIELD_NAME": "area_ha",
"FIELD_TYPE": 0, # Decimal (double)
"FIELD_LENGTH": 12,
"FIELD_PRECISION": 3,
"FORMULA": "round($area / 10000, 3)",
"OUTPUT": "TEMPORARY_OUTPUT",
})
out = result["OUTPUT"]
print(out.featureCount(), "features, fields", out.fields().names())
{0: 'Decimal (double)', 1: 'Integer (32 bit)', 2: 'Text (string)', 3: 'Date', 4: 'Time', 5: 'Date & Time', 6: 'Boolean', 7: 'Binary Object (BLOB)', 8: 'String List', 9: 'Integer List', 10: 'Decimal (double) List'}
21253 features, fields ['fid', 'INSPIREID', 'area_ha']
Index 4 is Time, not Boolean β a snippet that asks for a boolean with 4 gets a time column. Chain several calculations by feeding each result into the next:
steps = [
("area_ha", 0, "round($area / 10000, 3)"),
("perim_m", 0, "round($perimeter, 1)"),
("compact", 0, "round(4 * pi() * $area / ($perimeter ^ 2), 3)"),
("size_cls", 2, "CASE WHEN \"area_ha\" > 10 THEN 'large' "
"WHEN \"area_ha\" > 1 THEN 'medium' ELSE 'small' END"),
]
current = bng
for name, ftype, formula in steps:
current = processing.run("native:fieldcalculator", {
"INPUT": current, "FIELD_NAME": name, "FIELD_TYPE": ftype,
"FORMULA": formula, "OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]
processing.run("native:savefeatures", {"INPUT": current, "OUTPUT": "out/parcels_calc.gpkg"})
from collections import Counter
print(Counter(f["size_cls"] for f in current.getFeatures()))
Counter({'small': 18554, 'medium': 1773, 'large': 926})
Note that size_cls refers to area_ha, which the first step created β the chain works because each step's output carries the new field.
Update values efficiently
Setting one attribute at a time through the edit buffer issues one buffered change per feature. A batch dictionary through the provider is one call:
import time
layer = parcels_copy("batch")
layer.dataProvider().addAttributes([QgsField("area_ha", QMetaType.Type.Double)])
layer.updateFields()
idx = layer.fields().indexOf("area_ha")
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
expr = QgsExpression("round($area / 10000, 3)")
expr.prepare(context)
start = time.perf_counter()
changes = {}
for feature in layer.getFeatures():
context.setFeature(feature)
value = expr.evaluate(context)
if not expr.hasEvalError():
changes[feature.id()] = {idx: value}
evaluated = time.perf_counter() - start
start = time.perf_counter()
layer.startEditing()
for fid, attrs in changes.items():
layer.changeAttributeValue(fid, idx, attrs[idx])
layer.commitChanges()
buffered = time.perf_counter() - start
start = time.perf_counter()
layer.dataProvider().changeAttributeValues(changes) # one call
layer.reload()
direct = time.perf_counter() - start
print(f"evaluate {len(changes):,}: {evaluated:.2f} s | write via edit buffer {buffered:.2f} s | via provider {direct:.2f} s")
evaluate 21,253: 0.09 s | write via edit buffer 0.19 s | via provider 0.09 s
Going through the provider bypasses the edit buffer, which is quicker and keeps nothing in memory for undo. Use the edit-session form when the layer is open in a GUI, and the provider form in a headless batch. At 21,253 parcels both are well under a second; the difference matters on millions of rows.
Add a virtual field when the value should stay live
A virtual field stores the expression, not the result, and re-evaluates as the data changes.
field = QgsField("area_ha_live", QMetaType.Type.Double)
live = parcels_copy("virtual")
live.addExpressionField("round($area / 10000, 3)", field)
first = next(live.getFeatures())
print(first["INSPIREID"], first["area_ha_live"], "| in the file:", "area_ha_live" in
QgsVectorLayer("work/virtual.gpkg|layername=parcels", "reopened", "ogr").fields().names())
32383481 32.111 | in the file: False
Virtual fields live in the project, not in the data file, so they suit a .qgz deliverable and are useless for a GeoPackage handed to someone else.
Filter with the same engine β carefully
Expressions are not only for calculation, but a layer's subset string is not an expression. setSubsetString passes its text to the provider, which for a GeoPackage is SQL β and QGIS-only syntax fails without an error:
filtered = parcels_copy("subset")
print("$area > 100000: ", filtered.setSubsetString("$area > 100000"), filtered.featureCount())
filtered.setSubsetString("")
print("ST_Area(geometry) > 1e5:", filtered.setSubsetString("ST_Area(geometry) > 100000"), filtered.featureCount())
filtered.setSubsetString("")
$area > 100000: True 0
ST_Area(geometry) > 1e5: True 926
setSubsetString returned True both times; the QGIS expression simply matched nothing. Use the provider's SQL there, or iterate a request, which does take a QGIS expression, without changing the layer's state:
from qgis.core import QgsFeatureRequest
request = QgsFeatureRequest(QgsExpression("$area > 100000"))
request.setSubsetOfAttributes(["INSPIREID"], filtered.fields()) # read less
print(sum(1 for _ in filtered.getFeatures(request)), "parcels over 10 ha")
926 parcels over 10 ha
Handle nulls and types deliberately
context = QgsExpressionContext()
for formula in ("NULL + 1", "coalesce(NULL, 0) + 1", "10 / nullif(0, 0)",
"CASE WHEN 0 IS NULL OR 0 = 0 THEN NULL ELSE round(10 / 0, 2) END"):
print(f"{formula:62} -> {QgsExpression(formula).evaluate(context)!r}")
NULL + 1 -> None
coalesce(NULL, 0) + 1 -> 1
10 / nullif(0, 0) -> None
CASE WHEN 0 IS NULL OR 0 = 0 THEN NULL ELSE round(10 / 0, 2) END -> None
Any arithmetic with NULL is NULL, and QGIS expression NULL arrives in Python as None, so check before using the result in arithmetic on the Python side.
Calculated fields on Rutland's parcels and wards
The data used on this page
HM Land Registry's INSPIRE Index Polygons for Rutland (Open Government Licence, Β© Crown copyright and database rights) and Rutland's 15 wards from the ONS Wards (December 2024) boundaries, both in the British National Grid. parcels_copy() gives each example a fresh copy of the parcels, so no example sees another's fields. QGIS 4.2 runs headless and is closed at the end of the page.
import os
import shutil
from pathlib import Path
import requests
from osgeo import gdal
gdal.UseExceptions()
Path("data").mkdir(exist_ok=True)
Path("work").mkdir(exist_ok=True)
Path("out").mkdir(exist_ok=True)
if not Path("data/parcels.gpkg").exists():
reply = requests.Session().get(
"https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300)
reply.raise_for_status()
Path("data/Rutland.zip").write_bytes(reply.content)
gdal.VectorTranslate("data/parcels.gpkg", "/vsizip/data/Rutland.zip/Land_Registry_Cadastral_Parcels.gml",
layerName="parcels", selectFields=["INSPIREID"])
if not Path("data/wards.geojson").exists():
Path("data/wards.geojson").write_bytes(requests.get(
"https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
"Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query",
params={"where": "LAD24CD='E06000017'", "outFields": "WD24CD,WD24NM", "outSR": 27700, "f": "geojson"},
timeout=120).content)
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from qgis.core import Qgis, QgsApplication, QgsProject, QgsVectorLayer
qgs = QgsApplication([], False)
qgs.initQgis()
from processing.core.Processing import Processing
Processing.initialize()
def parcels_copy(name):
shutil.copy("data/parcels.gpkg", f"work/{name}.gpkg")
return QgsVectorLayer(f"work/{name}.gpkg|layername=parcels", name, "ogr")
wards = QgsVectorLayer("data/wards.geojson", "wards", "ogr")
print("QGIS", Qgis.QGIS_VERSION, "|", parcels_copy("census").featureCount(), "parcels,", wards.featureCount(), "wards")
QGIS 4.2.2-BelΓ©m do ParΓ‘ | 21253 parcels, 15 wards
Example 1: a reusable calculator function
"""field_calc.py β apply expressions to a layer, with validation and a report."""
TYPES = {"double": QMetaType.Type.Double, "int": QMetaType.Type.Int,
"string": QMetaType.Type.QString, "bool": QMetaType.Type.Bool}
def calculate(layer: QgsVectorLayer, field_name: str, formula: str,
field_type: str = "double", use_provider: bool = True) -> dict:
expr = QgsExpression(formula)
if expr.hasParserError():
raise ValueError(f"{field_name}: {expr.parserErrorString()}")
missing = sorted(set(expr.referencedColumns()) - set(layer.fields().names()) - {QgsFeatureRequest.ALL_ATTRIBUTES})
if missing:
raise ValueError(f"{field_name}: expression needs missing fields {missing}")
if layer.fields().indexOf(field_name) == -1:
layer.dataProvider().addAttributes([QgsField(field_name, TYPES[field_type])])
layer.updateFields()
idx = layer.fields().indexOf(field_name)
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
expr.prepare(context)
changes, errors, nulls = {}, [], 0
for feature in layer.getFeatures():
context.setFeature(feature)
value = expr.evaluate(context)
if expr.hasEvalError():
errors.append((feature.id(), expr.evalErrorString()))
continue
if value is None:
nulls += 1
changes[feature.id()] = {idx: value}
if use_provider:
layer.dataProvider().changeAttributeValues(changes)
layer.reload()
else:
layer.startEditing()
for fid, attrs in changes.items():
for i, v in attrs.items():
layer.changeAttributeValue(fid, i, v)
layer.commitChanges()
return {"field": field_name, "updated": len(changes),
"nulls": nulls, "errors": len(errors), "first_errors": errors[:5]}
layer = parcels_copy("calculator")
print(calculate(layer, "area_ha", "round($area / 10000, 3)"))
try:
calculate(layer, "density", '"parcels" / "area_ha"')
except ValueError as error:
print("ValueError:", error)
unchecked = QgsExpression('"parcels" / "area_ha"') # what happens without the check
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
context.setFeature(next(layer.getFeatures()))
print("unchecked:", repr(unchecked.evaluate(context)), "| eval error:", unchecked.hasEvalError(), unchecked.evalErrorString())
{'field': 'area_ha', 'updated': 21253, 'nulls': 0, 'errors': 0, 'first_errors': []}
ValueError: density: expression needs missing fields ['parcels']
unchecked: None | eval error: True Field 'parcels' not found
The schema check turns a missing column into an error before anything is written. Without it, a reference to a field that does not exist does not raise: it returns NULL and sets an evaluation error that only a loop checking hasEvalError() will notice.
Example 2: apply a whole set of expressions from config
import yaml
Path("configs").mkdir(exist_ok=True)
Path("configs/calculations.yml").write_text("""\
- field: area_ha
type: double
formula: round($area / 10000, 3)
- field: compact
type: double
formula: round(4 * pi() * $area / ($perimeter ^ 2), 3)
- field: size_cls
type: string
formula: "CASE WHEN \\"area_ha\\" > 10 THEN 'large' WHEN \\"area_ha\\" > 1 THEN 'medium' ELSE 'small' END"
""")
CALCS = yaml.safe_load(Path("configs/calculations.yml").read_text())
layer = parcels_copy("config")
for calc in CALCS:
report = calculate(layer, calc["field"], calc["formula"], calc.get("type", "double"))
status = "ok" if not report["errors"] else f"{report['errors']} errors"
print(f"{calc['field']:<10} {report['updated']:>7,} updated, {report['nulls']:>5} null {status}")
area_ha 21,253 updated, 0 null ok
compact 21,253 updated, 0 null ok
size_cls 21,253 updated, 0 null ok
Ordering matters: size_cls depends on area_ha, so the list is also a dependency order.
Example 3: aggregates and geometry functions
def evaluate_once(formula: str, layer):
"""Evaluate a layer-level expression, e.g. an aggregate."""
expr = QgsExpression(formula)
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
value = expr.evaluate(context)
if expr.hasEvalError():
raise ValueError(expr.evalErrorString())
return value
layer = parcels_copy("aggregates")
print("total area ha :", round(evaluate_once("sum($area) / 10000", layer), 1))
print("median area mΒ²:", round(evaluate_once("aggregate(@layer, 'median', $area)", layer)))
print("wards :", evaluate_once("array_to_string(array_sort(aggregate(@layer, 'array_agg', \"WD24NM\")), ', ')", wards)[:80], "β¦")
total area ha : 42898.5
median area mΒ²: 424
wards : Barleythorpe, Braunston & Martinsthorpe, Cottesmore, Exton, Greetham, Ketton, La β¦
Aggregates across another layer work too β the expression engine's spatial join in a string. Here each ward counts the parcels whose point-on-surface falls inside it; the parcels layer has to be in the project so the expression can find it by name:
QgsProject.instance().addMapLayer(layer)
formula = """
aggregate(
layer := 'aggregates',
aggregate := 'count',
expression := "INSPIREID",
filter := within(point_on_surface($geometry), geometry(@parent))
)
"""
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(wards))
counts = {}
for ward in wards.getFeatures():
context.setFeature(ward)
counts[ward["WD24NM"]] = QgsExpression(formula).evaluate(context)
print(sorted(counts.items(), key=lambda kv: -kv[1])[:3], "| total", sum(counts.values()))
QgsProject.instance().removeMapLayer(layer.id())
[('Uppingham', 2053.0), ('Oakham South', 1981.0), ('Braunston & Martinsthorpe', 1905.0)] | total 21007.0
Example 4: check a scripted result against the GUI
from qgis.core import QgsFeatureRequest
# GUI-equivalent path: the Processing algorithm
alg_out = processing.run("native:fieldcalculator", {
"INPUT": "data/parcels.gpkg|layername=parcels",
"FIELD_NAME": "area_ha", "FIELD_TYPE": 0,
"FORMULA": "round($area / 10000, 3)", "OUTPUT": "TEMPORARY_OUTPUT"})["OUTPUT"]
# scripted path
scripted = parcels_copy("compare")
calculate(scripted, "area_ha", "round($area / 10000, 3)")
a = {f["INSPIREID"]: f["area_ha"] for f in alg_out.getFeatures()}
b = {f["INSPIREID"]: f["area_ha"] for f in scripted.getFeatures()}
diffs = {k: (a[k], b[k]) for k in a if abs((a[k] or 0) - (b[k] or 0)) > 1e-9}
print(f"{len(a):,} parcels compared: " + (f"{len(diffs)} differing values" if diffs else "identical results"))
21,253 parcels compared: identical results
Comparing the two paths once is the cheapest way to be sure a rewritten calculation still matches what the GUI produced.
The page's QGIS session ends here:
QgsProject.instance().clear()
qgs.exitQgis()
print("QGIS closed")
QGIS closed
A small language inside QGIS
QGIS expressions are a small language with its own parser, function library and type rules, implemented in C++. Field Calculator, labelling, styling, atlas coverage, data-defined overrides and feature requests all evaluate the same language, which is why reusing the engine from Python is more faithful than re-implementing the logic in pandas or plain Python.
Evaluation always needs two things: the expression and a context. The context is a stack of scopes β global, project, layer, then feature β each contributing variables, functions and settings. @layer, @project_* variables and the unit conversion of $area and $length come from those scopes, so the same expression can give different answers in the GUI, where the context is assembled for you, and in a script, where it is not. On a projected layer the difference is often invisible; on a geographic one it is the difference between square degrees, an inflated number of square metres, and the right answer.
Performance follows the usual rule: parse once, evaluate many times, write once. On the Rutland parcels the whole evaluation took a fraction of a second, and writing through the provider in one call was quicker than the edit buffer, which keeps every change in memory for undo β exactly what an unattended batch does not need.
Choosing between the expression engine and GeoPandas is mostly about fidelity and context. If the calculation must match a QGIS project β the same rounding, the same null semantics, the same geometry functions, an expression referenced by a style β use the engine. If the work is a bulk numerical transformation and the output leaves QGIS anyway, GeoPandas is usually simpler. The one thing not to do is re-implement a non-trivial QGIS expression by hand in Python and assume the results match; if you must, compare the two paths once and keep the comparison as a test.
Quoting, missing fields and units
- Field name quoting: Double quotes are field references, single quotes are string literals.
"fclass" = 'residential'β swapping them is the most common expression error. - Missing fields do not raise: a reference to a field the layer lacks returns
NULLwith an evaluation error (Field 'parcels' not found). CheckreferencedColumns()against the schema first. $areaon a geographic layer: square degrees without scopes, an equator-scaled conversion with them β 64% too large at 52.6Β°N. Set an ellipsoid (project orQgsDistanceArea) or reproject first.- Subset strings are provider SQL:
setSubsetString("$area > 100000")returnedTrueand matched nothing. UseST_Area(geometry)for a GeoPackage, or aQgsFeatureRequestfor a QGIS expression. FIELD_TYPEindices: 0 double, 1 integer, 2 string, 3 date, 4 time, 5 date-time, 6 boolean in QGIS 4.2 β print the options rather than trusting a snippet.- New fields need
updateFields(): AfteraddAttributeoraddAttributes, the index is not available until you refresh the field list. - Provider edits bypass undo:
dataProvider().changeAttributeValues()is quick and irreversible. Use the edit session when a user might want to roll back. - Virtual fields do not persist to the data: They live in the project file. Materialise with the Field Calculator algorithm before exporting.
Internal links
- How to Run QGIS Processing Algorithms from Python
- How to Automate QGIS with Python (PyQGIS): The Complete Workflow
- How to Add, Edit and Delete Features with PyQGIS
- How to Move Data Between QGIS and GeoPandas
- PyQGIS Layer Fails to Load (isValid() Returns False): How to Fix It
- PyQGIS vs GeoPandas: Which to Use for GIS Automation
FAQ
Why does my expression return NULL for $area?
Usually the context has no feature: call context.setFeature(feature) inside the loop. And append QgsExpressionContextUtils.globalProjectLayerScopes(layer) β on QGIS 4.2 $area still evaluated without it, but in the layer's units, which for a geographic layer are square degrees.
Should I use QgsExpression or the native:fieldcalculator algorithm?
The algorithm for straightforward whole-layer calculations β less code and it manages the edit session. QgsExpression when you need per-feature control, custom error handling, or to evaluate without writing anything.
How do I make per-feature evaluation fast?
Parse and prepare once before the loop, collect the results into a dictionary, and apply them with a single dataProvider().changeAttributeValues() call. On Rutland's 21,253 parcels evaluating took a fraction of a second; the write was the part worth batching.
What is the difference between a virtual field and a calculated field?
A virtual field stores the expression and re-evaluates it live, but exists only in the project. A calculated field stores values in the data and travels with the file.
How do I filter features with an expression?
Pass a QgsExpression to QgsFeatureRequest to iterate a subset without changing the layer's state. layer.setSubsetString(...) filters the layer persistently, but it takes the provider's SQL, not a QGIS expression: "$area > 100000" returned True and matched nothing on a GeoPackage.
Can I use aggregates across another layer?
Yes β aggregate() and relation_aggregate() evaluate against another layer, and can take a filter using intersects($geometry, geometry(@parent)) for a spatial relationship.
Why do my results differ from the GUI?
Usually units or context: what $area returns depends on the scopes and on whether an ellipsoid is set, which matters most on a geographic layer. Run the same expression through native:fieldcalculator and compare values to find out.