How to Run Field Calculator Expressions from PyQGIS

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 layer'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 is C++ and fast, and it can use a spatial index for aggregates
  • a virtual field re-evaluates on the fly, with no data written at all

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 prepare a context that knows about the layer
  3. evaluate per feature inside an edit session, or use the Processing algorithm for a whole layer
  4. always check expression.hasParserError() before evaluating
  5. commit the changes, and verify the values
from qgis.core import (
    QgsVectorLayer, QgsExpression, QgsExpressionContext,
    QgsExpressionContextUtils, QgsField,
)
from qgis.PyQt.QtCore import QVariant

layer = QgsVectorLayer("data/parcels.gpkg|layername=parcels", "parcels", "ogr")
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", QVariant.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()
print(f"updated {layer.featureCount()} features")

expr.prepare(context) is the line that makes this fast: it resolves field references once instead of per feature.

The anatomy of an expression evaluation

Anatomy of an expression evaluation: expression, context scopes, feature, result.
The context is what turns a string into a value β€” without scopes, `$area` and variables are unknown.

Step-by-step solution

Vertical steps: parse, build context, add field, evaluate per feature, commit, verify.
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("street", \' \', "town")')
if expr.hasParserError():
    raise ValueError(f"{expr.expression()}: {expr.parserErrorString()}")

print("referenced columns:", expr.referencedColumns())
print("needs geometry    :", expr.needsGeometry())

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.

from qgis.core import QgsExpressionContext, QgsExpressionContextUtils

context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))

# add your own variables, usable as @batch_date in the expression
scope = QgsExpressionContextUtils.projectScope(QgsProject.instance())
QgsExpressionContextUtils.setProjectVariable(QgsProject.instance(), "batch_date", "2026-08-11")

expr = QgsExpression("concat(\"parcel_id\", '-', @batch_date)")

Without the layer scope, $area, $length and $id do not resolve and every evaluation returns NULL β€” the single most common reason a scripted expression "does nothing".

Use the Processing algorithm for whole-layer work

For a straightforward calculation over a whole layer, the algorithm is less code and handles the edit session for you.

import processing

result = processing.run("native:fieldcalculator", {
    "INPUT": layer,
    "FIELD_NAME": "area_ha",
    "FIELD_TYPE": 0,            # 0 float, 1 integer, 2 string, 3 date, 4 bool
    "FIELD_LENGTH": 12,
    "FIELD_PRECISION": 3,
    "FORMULA": "round($area / 10000, 3)",
    "OUTPUT": "TEMPORARY_OUTPUT",
})
out = result["OUTPUT"]
print(out.featureCount(), "features")

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, '4 * pi() * $area / ($perimeter ^ 2)'),
    ("size_cls", 2, "CASE WHEN \"area_ha\" > 10 THEN 'large' "
                    "WHEN \"area_ha\" > 1 THEN 'medium' ELSE 'small' END"),
]

current = layer
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": "data/out/parcels_calc.gpkg"})

Note that size_cls refers to area_ha, which the previous step created β€” the chain works because each step's output carries the new field.

Update values efficiently

Setting one attribute at a time issues one change per feature. A batch dictionary is much faster on large layers.

expr.prepare(context)
changes = {}
for feature in layer.getFeatures():
    context.setFeature(feature)
    value = expr.evaluate(context)
    if not expr.hasEvalError():
        changes[feature.id()] = {idx: value}

layer.dataProvider().changeAttributeValues(changes)     # one call
layer.reload()

Going through the provider bypasses the edit buffer, which is faster and uses far less memory β€” at the cost of no undo. Use the edit-session form when the layer is open in a GUI, and the provider form in a headless batch.

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.

from qgis.core import QgsField, QgsExpression
from qgis.PyQt.QtCore import QVariant

field = QgsField("area_ha_live", QVariant.Double)
layer.addExpressionField("round($area / 10000, 3)", field)

for f in layer.getFeatures():
    print(f["area_ha_live"])
    break

Virtual fields live in the project, not in the data file, so they are perfect for a .qgz deliverable and useless for a GeoPackage handed to someone else.

Filter with the same engine

Expressions are not only for calculation. setSubsetString pushes a filter down to the provider.

layer.setSubsetString('"class" = \'residential\' AND $area > 500')
print("filtered:", layer.featureCount())
layer.setSubsetString("")           # clear it

Or iterate a request without changing the layer's state:

from qgis.core import QgsFeatureRequest

request = QgsFeatureRequest(QgsExpression('"pop" > 5000'))
request.setSubsetOfAttributes(["name", "pop"], layer.fields())     # read less
for feature in layer.getFeatures(request):
    print(feature["name"], feature["pop"])

Handle nulls and types deliberately

# NULL propagates: any arithmetic with NULL is NULL
expr = QgsExpression('coalesce("pop", 0) / nullif("area_ha", 0)')

# guard division and missing values explicitly
expr = QgsExpression("""
CASE
  WHEN "area_ha" IS NULL OR "area_ha" = 0 THEN NULL
  ELSE round(coalesce("pop", 0) / "area_ha", 2)
END
""")

QGIS expression NULL maps to a Python None (or an invalid QVariant on older versions), so check before using the result in arithmetic on the Python side.

Code examples

Example 1: a reusable calculator function

"""field_calc.py β€” apply expressions to a layer, with validation and a report."""
from qgis.core import (
    QgsVectorLayer, QgsExpression, QgsExpressionContext,
    QgsExpressionContextUtils, QgsField,
)
from qgis.PyQt.QtCore import QVariant

TYPES = {"double": QVariant.Double, "int": QVariant.Int,
         "string": QVariant.String, "bool": QVariant.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 = [c for c in expr.referencedColumns()
               if c != QgsExpression() and c not in layer.fields().names()
               and not c.startswith("$")]
    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]}

report = calculate(layer, "area_ha", "round($area / 10000, 3)")
print(report)

Example 2: apply a whole set of expressions from config

import yaml
from pathlib import Path

CALCS = yaml.safe_load(Path("configs/calculations.yml").read_text())
# calculations.yml
# - field: area_ha
#   type: double
#   formula: round($area / 10000, 3)
# - field: density
#   type: double
#   formula: "CASE WHEN \"area_ha\" > 0 THEN round(\"pop\" / \"area_ha\", 1) END"

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']:<12} {report['updated']:>7} updated, "
          f"{report['nulls']:>5} null  {status}")

Ordering matters: density depends on area_ha, so the list is also a dependency order.

Example 3: aggregates and geometry functions

from qgis.core import QgsExpression, QgsExpressionContext, QgsExpressionContextUtils

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

print("total area ha :", evaluate_once("sum($area) / 10000", layer))
print("mean pop      :", evaluate_once('aggregate(@layer, \'mean\', "pop")', layer))
print("classes       :", evaluate_once('array_to_string(array_distinct(aggregate(@layer, '
                                       "'array_agg', \"class\")))", layer))

Aggregates across another layer work too β€” this is the expression engine's spatial-join-in-a-string trick:

formula = """
aggregate(
  layer := 'zones_layer_id',
  aggregate := 'max',
  expression := "zone_score",
  filter := intersects($geometry, geometry(@parent))
)
"""

Example 4: check a scripted result against the GUI

import processing

# 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 = QgsVectorLayer("data/parcels.gpkg|layername=parcels", "p", "ogr")
calculate(scripted, "area_ha", "round($area / 10000, 3)")

a = {f["parcel_id"]: f["area_ha"] for f in alg_out.getFeatures()}
b = {f["parcel_id"]: 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(diffs)} differing values" if diffs else "identical results")

Comparing the two paths once is the cheapest way to be sure a rewritten calculation still matches what the GUI produced.

Explanation

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 layer filters 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.

Grid comparing QGIS expressions, PyQGIS loops and GeoPandas across speed, fidelity and portability.
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 and functions. $area, $length, $id and @layer_name come from the layer and feature scopes, so an expression evaluated without them silently returns NULL. That is the single most common failure when moving an expression from the GUI, where the context is assembled for you, into a script, where it is not.

Performance follows a simple rule: parse once, prepare once, evaluate many times. QgsExpression.prepare() resolves field indices and function bindings against the context up front, so per-feature evaluation is a tight loop rather than a repeated lookup. On the write side, collecting changes into a dictionary and calling changeAttributeValues() once is dramatically faster than per-feature edits through the edit buffer β€” the buffer keeps every change in memory for undo, which is 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 and faster still. 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.

Edge cases or notes

  • Field name quoting: Double quotes are field references, single quotes are string literals. "class" = 'residential' β€” swapping them is the most common expression error.
  • No layer scope, no $area: Always append globalProjectLayerScopes(layer), or geometry variables evaluate to NULL.
  • $area is in layer units: A layer in EPSG:4326 gives square degrees. Reproject first, or use area($geometry) with an ellipsoid set on the project.
  • 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 fast and irreversible. Use the edit session when a user might want to roll back.
  • Expression results are QVariant-flavoured: A NULL may arrive as None; guard before arithmetic on the Python side.
  • 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?

The context has no layer scope. Append QgsExpressionContextUtils.globalProjectLayerScopes(layer) before evaluating, and call context.setFeature(feature) inside the loop.

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?

Call expr.prepare(context) once before the loop, collect the results into a dictionary, and apply them with a single dataProvider().changeAttributeValues() call.

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?

layer.setSubsetString(...) filters the layer persistently, or pass a QgsExpression to QgsFeatureRequest to iterate a subset without changing the layer's state.

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: $area is in layer units unless an ellipsoid is set, and a missing layer scope changes what resolves. Run the same expression through native:fieldcalculator and compare values to find out.