Use Expression Context and Scopes in PyQGIS

An expression such as @project_title || ' — ' || "name" works in a label, but evaluated in a script it returns NULL for the project title and fails on the field. The difference is the expression context: the set of variables, fields and the current feature that an expression can see. Inside QGIS, every label, style and layout item builds a context automatically. In scripts, you build it — and understanding its layered scopes is what makes scripted expressions behave exactly like the ones in the interface.

This recipe belongs to Working with QGIS Expressions. It explains the scope stack, builds contexts for layer evaluation, inspects which variables are available, adds custom scopes and variables, and uses scopes to make scripts and layouts configurable.

The scope stackAn expression context is a stack of scopes. The global scope holds application-wide variables such as qgis_version and user_account_name. The project scope adds project_title, project_crs and project variables. The layer scope adds layer_name, layer_id and layer variables. A feature scope holds the current feature and its fields. Later scopes override earlier ones, so a layer variable can override a project variable of the same name.Later scopes override earlier onesglobal: @qgis_version, @user_account_nameproject: @project_title, @project_crs, varslayer: @layer_name, @layer_id, layer varsfeature: "fields", $geometry, @idlookup orderthe expression sees the top-most value of each variable

Prerequisites

Build a context like QGIS does

QgsExpressionContextUtils creates the standard scopes. For an expression evaluated against a layer's features, the global, project and layer scopes plus the feature are what QGIS itself uses.

from qgis.core import (QgsProject, QgsExpression, QgsExpressionContext,
                       QgsExpressionContextUtils)

layer = QgsProject.instance().mapLayersByName("districts")[0]
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))

expr = QgsExpression("@project_title || ': ' || \"name\" || ' (' || @layer_name || ')'")
expr.prepare(context)
for f in list(layer.getFeatures())[:3]:
    context.setFeature(f)
    print(expr.evaluate(context))

Breakdown: globalProjectLayerScopes returns the three scopes in order, and appendScopes stacks them. setFeature puts the current feature into the context, which makes field references and $geometry work. Preparing once and setting the feature per iteration is the efficient pattern. Without the project scope, @project_title is NULL; without the layer scope, @layer_name is NULL and — because the fields come from the layer — field names may not resolve during preparation.

See which variables are available

When an expression returns NULL unexpectedly, the first question is whether the variable exists in the context. The context can list them.

for name in sorted(context.variableNames()):
    value = context.variable(name)
    if not name.startswith("_"):
        print(f"@{name:<28} {str(value)[:60]}")

print("highest scope defining project_title:",
      context.indexOfScope(context.scopeForVariable("project_title").name())
      if context.scopeForVariable("project_title") else None)

Breakdown: variableNames lists every variable visible in the context, from all scopes. Printing them with values is the quickest way to discover what is on offer — @project_crs, @project_folder, @user_full_name, @layer_crs and many more. scopeForVariable returns the scope that provides the value actually used, which explains surprises when the same name is defined at several levels. Variables starting with an underscore are internal and best ignored.

Set project and layer variables

Variables are a configuration mechanism: values defined once at project or layer level and used in labels, styles, layouts and expressions across the project.

Variables at different levelsA project variable report_year is set to 2026 and used in every layout title and filter. A layer variable on one layer sets a threshold used only in that layer's style. A global variable, organisation_name, set once in user settings, appears in every project. Overriding a project variable with a layer variable of the same name lets one layer use a different value.Define once, use everywhere belowglobalorganisation_nameevery projectprojectreport_year = 2026all layers, layoutslayeralert_thresholdthis layer only

project = QgsProject.instance()
QgsExpressionContextUtils.setProjectVariable(project, "report_year", 2026)
QgsExpressionContextUtils.setProjectVariable(project, "data_folder", "/data/2026")
QgsExpressionContextUtils.setLayerVariable(layer, "alert_threshold", 1500)
QgsExpressionContextUtils.setGlobalVariable("organisation_name", "City Planning Office")

ctx = QgsExpressionContext()
ctx.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
print(QgsExpression("@organisation_name || ' ' || @report_year").evaluate(ctx))

Breakdown: Project variables are saved in the project file; layer variables are saved with the layer in the project and in its QML style; global variables live in the user's settings and apply to every project on that machine. A label using @report_year updates everywhere when the project variable changes — one edit instead of hunting through layouts. A layer variable with the same name as a project variable overrides it for that layer only, which is how one layer can use a stricter threshold than the rest.

Add a custom scope

Scripts sometimes need their own variables — a run date, a batch id, the current tile in a loop — without writing them into the project. A custom scope added to the context provides them for the duration of the evaluation.

A temporary scope on topA script appends its own scope, named batch run, on top of the standard stack. Its variables, such as run_id and min_area, are visible to expressions and override project or layer variables with the same names, but exist only in this context and are never written to the project.Script variables without touching the projectstandard scopesglobal, project,layer, feature+ batch run scoperun_idmin_areaexpressionssee run valuesproject unchanged

from qgis.core import QgsExpressionContextScope

run_scope = QgsExpressionContextScope("batch run")
run_scope.setVariable("run_id", "2026-10-02-A")
run_scope.setVariable("tile_name", "T_571_5934")
run_scope.setVariable("min_area", 500, isStatic=True)

ctx = QgsExpressionContext()
ctx.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
ctx.appendScope(run_scope)

flt = QgsExpression('$area >= @min_area')
flt.prepare(ctx)
kept = 0
for f in layer.getFeatures():
    ctx.setFeature(f)
    if flt.evaluate(ctx):
        kept += 1
print(kept, "features above @min_area in run", ctx.variable("run_id"))

Breakdown: A scope appended last sits at the top of the stack, so its variables override any project or layer variables with the same names — useful for temporarily changing a threshold without editing the project. Marking a variable static tells QGIS its value will not change during evaluation, which allows some optimisations. The context owns appended scopes, so create a new scope object for each context rather than reusing one. Custom scopes are also how plugins pass values to expressions in their own dialogs.

Debug expressions that return NULL

Most expression problems show up as NULL. A short diagnostic separates the three usual causes — a parse error, a missing variable or field, an evaluation error — so you know which to fix.

def diagnose(text, layer, feature=None):
    ctx = QgsExpressionContext(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
    e = QgsExpression(text)
    if e.hasParserError():
        return f"parse error: {e.parserErrorString()}"
    missing_vars = [v for v in e.referencedVariables() if v not in ctx.variableNames() and v]
    missing_fields = [c for c in e.referencedColumns()
                      if c and c != QgsFeatureRequest.ALL_ATTRIBUTES and c not in layer.fields().names()]
    if missing_vars or missing_fields:
        return f"missing variables {missing_vars} / fields {missing_fields}"
    ctx.setFeature(feature or next(layer.getFeatures()))
    value = e.evaluate(ctx)
    if e.hasEvalError():
        return f"evaluation error: {e.evalErrorString()}"
    return f"value: {value!r}"

from qgis.core import QgsFeatureRequest
print(diagnose("@report_yr || ' ' || \"nme\"", layer))

Breakdown: Checking the parser first catches syntax mistakes. referencedVariables and referencedColumns list what the expression needs; comparing them with what the context and layer provide catches typos — here @report_yr and "nme" — before evaluation silently returns NULL. Only then is the expression evaluated against a real feature, where type errors such as adding text to a number appear as evaluation errors. Wrapping this in a helper makes it a one-line check in any script.

Pass scopes to Processing and layouts

Processing algorithms and layouts build their own contexts. Variables set at project level reach them automatically; for per-run values, set them where those components will look.

import processing
from qgis.core import QgsProcessingContext

pctx = QgsProcessingContext()
pctx.setProject(project)
exp_ctx = pctx.expressionContext()
exp_ctx.appendScope(QgsExpressionContextScope("run"))
exp_ctx.lastScope().setVariable("min_area", 500)
pctx.setExpressionContext(exp_ctx)

big = processing.run("native:extractbyexpression", {
    "INPUT": layer, "EXPRESSION": "$area >= @min_area", "OUTPUT": "memory:"},
    context=pctx)["OUTPUT"]
print(big.featureCount(), "large districts")

Breakdown: A Processing context carries an expression context that algorithms use when they evaluate expression parameters. Adding a scope to it makes run-specific variables available to expressions inside the algorithm, exactly as a model's variables would be. For layouts, set layout variables with setLayoutVariable, which add a layout scope between project and item scopes, as used in loading a layout from a template.

Read variables in custom functions

Custom expression functions receive the context too, so they can read variables — letting one function behave differently per project or layer without extra arguments.

from qgis.core import qgsfunction

@qgsfunction(args="auto", group="Custom", usesgeometry=False, referenced_columns=[])
def above_threshold(value, feature, parent, context):
    threshold = context.variable("alert_threshold") or 1000
    return value is not None and value > threshold

expr = QgsExpression('above_threshold("population")')
ctx = QgsExpressionContext(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
f = next(layer.getFeatures())
ctx.setFeature(f)
print(expr.evaluate(ctx))

Breakdown: Declaring a context parameter gives the function access to the evaluation context, from which it reads the layer variable set earlier; a default applies where the variable is not defined. This keeps expressions short — above_threshold("population") — while letting each layer set its own threshold. Registering a custom expression function covers registration and distribution.

QGIS version compatibility

QgsExpressionContext, the scope utilities and custom scopes work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. The context argument for @qgsfunction functions is available on all current releases. Variable names are stable; new built-in variables are added in most releases, so list them on your version.

Troubleshooting

  • Variables evaluate to NULL in scripts. The project or layer scope is missing from the context.
  • Field references fail during preparation. The layer scope was not added, so field names are unknown.
  • A variable has an unexpected value. A higher scope overrides it; check scopeForVariable.
  • Project variables are lost. The project was not saved after setting them.

Conclusion

Build contexts with the global, project and layer scopes plus the feature, inspect available variables when results are NULL, use project, layer and global variables as configuration, add custom scopes for per-run values, pass scopes into Processing contexts, and read variables in custom functions to keep expressions short.

Frequently Asked Questions

What is the difference between @id and $id? Both give the feature id; @id is the variable form, $id the older function form.

Can variables hold geometries? Yes — scopes can hold any value, including geometries, as atlas and map scopes do.

Are variables available in the field calculator? Yes; the calculator builds the same global, project and layer context.

Do layer variables travel with a QML style? Yes. Layer variables are saved in the layer's style, so loading the QML on another layer brings them along — handy for thresholds that belong to a style.

How do I remove a project variable? Use QgsExpressionContextUtils.removeProjectVariable(project, name).