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:

  1. add the target field to the layer (or let the algorithm create it)
  2. build a QgsExpression and a context that knows about the project and the layer
  3. evaluate per feature, or use the Processing algorithm for a whole layer
  4. always check expression.hasParserError() before evaluating
  5. 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

An expression evaluation: expression, context scopes, feature, result. an expression needs a contextround($area / 10000, 3)global scope@qgis_versionproject scope@project_crslayer scope@layer_namefeature scope$area, $idevaluate(context)β†’ 1.284Without the layer scope, $area is NULL and nothing happens spatialworkflow.io
The context is what turns a string into a value β€” without scopes, $area and variables are unknown.

Parse, give it context, then update efficiently

Applying an expression to a layer, from parsing to verification. parse first, evaluate last1parse and check for errorsmilliseconds2build the context scopesglobal β†’ feature3add the target fieldand updateFields()4prepare(), then evaluateper feature5apply in one callchangeAttributeValues6verify the valuesnulls, eval errorsprepare() resolves fields once instead of once per feature spatialworkflow.io
Six steps β€” parsing first means a typo fails in milliseconds, not after 400,000 features.

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
The value of $area for one Rutland parcel under five combinations of layer CRS, context scopes and geometry calculator. one parcel, five answers for $arealayercontext$areaBritish National Gridno scopes321,109 mΒ²British National Gridproject + layer scopes321,109 mΒ²WGS 84no scopes0.0000426 square degreesWGS 84project + layer scopes527,869 mΒ² (64% high)WGS 84ellipsoidal calculator321,276 mΒ²On a geographic layer, only an ellipsoid gives the real area spatialworkflow.io
Measured above under QGIS 4.2, with no ellipsoid set on the project.

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.

Expression mistakes that QGIS 4.2 accepted without raising, found on the Rutland parcels. accepted without an exceptionwhat the script didwhat came backsetSubsetString("$area > 100000")True, and 0 featuresFIELD_TYPE 4 for a booleana Time fielda field the layer lacksNULL + an eval error$area on a WGS 84 layer64% too large, in mΒ²None of them raised; each needs its own check spatialworkflow.io
All four are measured on this page.

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.

QGIS expressions, PyQGIS loops and GeoPandas compared. three ways to compute a fieldexpressionPyQGIS loopGeoPandasmatches the GUIβœ“exactly!dependsβœ•nospeed on 1M rowsβœ“fastβœ•slowβœ“fastestruns outside QGISβœ•noβœ•noβœ“yesbest forproject fidelitycustom logicbulk mathsUse the engine when the result must match a QGIS project spatialworkflow.io
Three ways to compute a field, and what each one is actually good at.

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 NULL with an evaluation error (Field 'parcels' not found). Check referencedColumns() against the schema first.
  • $area on a geographic layer: square degrees without scopes, an equator-scaled conversion with them β€” 64% too large at 52.6Β°N. Set an ellipsoid (project or QgsDistanceArea) or reproject first.
  • Subset strings are provider SQL: setSubsetString("$area > 100000") returned True and matched nothing. Use ST_Area(geometry) for a GeoPackage, or a QgsFeatureRequest for a QGIS expression.
  • FIELD_TYPE indices: 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(): After addAttribute or addAttributes, 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.

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.