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.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- Basic familiarity with evaluating expressions, as in evaluating a QGIS expression in PyQGIS.
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.
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.
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).