Evaluate a QGIS Expression in PyQGIS
Running a QGIS expression from Python is a three-line job once you know the shape, and a source of quiet wrong answers until you do. The two objects involved — QgsExpression, which holds the parsed expression, and QgsExpressionContext, which supplies everything the expression refers to — have to be created in the right order and checked at two separate points, because a syntax error and a per-feature evaluation failure are different conditions reported through different methods.
This page is a focused recipe within Working with QGIS Expressions in PyQGIS. It covers evaluating an expression against a single feature, evaluating without any feature at all, checking both error channels, and preparing the expression so a loop over a large layer stays fast.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer, with the Python Console available.
- A loaded vector layer with at least one numeric field.
- A basic grasp of how the expression engine resolves names — the scope stack is the one concept worth reading first.
Evaluate against a single feature
The minimal working version builds a context, pushes the standard scopes, sets a feature, and evaluates.
from qgis.core import (
QgsExpression,
QgsExpressionContext,
QgsExpressionContextUtils,
QgsProject,
)
layer = QgsProject.instance().mapLayersByName("parcels")[0]
feature = next(layer.getFeatures())
expression = QgsExpression('"assessed_value" * 1.05')
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
context.setFeature(feature)
print(expression.evaluate(context))
Breakdown: globalProjectLayerScopes() returns the global, project and layer scopes already populated and ordered, so you never have to remember which variables belong where. setFeature() adds the feature scope on top, which is what makes "assessed_value" resolvable. evaluate() returns a Python object converted from the underlying QVariant: an int or float for numbers, a str for text, and None for SQL NULL.
Note the quoting. "assessed_value" in double quotes is a field reference. Writing 'assessed_value' in single quotes would produce the literal fifteen-character string instead — valid syntax, silently wrong result.
Check both error channels
An expression can fail in two unrelated ways, and each has its own predicate. Checking only one is the most common defect in expression code.
expression = QgsExpression('"assessed_value" / "lot_area"')
if expression.hasParserError():
raise ValueError(f"syntax: {expression.parserErrorString()}")
value = expression.evaluate(context)
if expression.hasEvalError():
print(f"feature {feature.id()}: {expression.evalErrorString()}")
else:
print(value)
Breakdown: hasParserError() is meaningful immediately after construction and never changes — it reports unbalanced quotes, unknown function names, and malformed operators. hasEvalError() is reset on every evaluate() call and reports failures that depend on the data, such as dividing by a zero lot area or calling a geometry function on a feature with no geometry. Because it is per-call, it must be checked inside the loop, not once afterwards.
Evaluate without a feature
Expressions that only reference variables, constants or functions need no feature at all. This is how you build dynamic filenames, timestamps and titles that match what the GUI would produce.
from qgis.core import (
QgsExpression,
QgsExpressionContext,
QgsExpressionContextUtils,
QgsProject,
)
context = QgsExpressionContext()
context.appendScopes([
QgsExpressionContextUtils.globalScope(),
QgsExpressionContextUtils.projectScope(QgsProject.instance()),
])
for text in ('@qgis_version', "format_date(now(), 'yyyy-MM-dd')", '@project_title'):
print(text, '->', QgsExpression(text).evaluate(context))
Breakdown: Only the scopes the expression actually needs are pushed; there is no layer or feature involved, so adding them would be wasted work. format_date() and now() are expression-engine functions rather than Python ones — the point of using them here is that the same string works unchanged in a layout item or a data-defined output path.
Prepare before iterating
For a loop over more than a few hundred features, prepare() is worth the extra line. It resolves field names to indexes once against the context, so each evaluation skips a name lookup.
expression = QgsExpression('"assessed_value" / nullif("lot_area", 0)')
if expression.hasParserError():
raise ValueError(expression.parserErrorString())
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
expression.prepare(context)
results = {}
for feature in layer.getFeatures():
context.setFeature(feature)
value = expression.evaluate(context)
if expression.hasEvalError():
continue
results[feature.id()] = value
print(len(results), "feature(s) evaluated cleanly")
Breakdown: prepare() must come after the scopes are appended, because it resolves against whatever the context currently knows. nullif("lot_area", 0) converts a zero area to NULL so the division yields NULL rather than raising an evaluation error — the expression engine's idiom for a guarded divide, and usually preferable to catching the error afterwards. Building a dictionary keyed by feature ID makes the result easy to feed into a bulk attribute update, as described in Update Attribute Values in Bulk with PyQGIS.
Inspect what the expression will touch
Before running an expression you did not write yourself — one pulled from a project file, a config, or a user dialog — it is worth asking the engine what it references. Three introspection methods answer that without evaluating anything.
expression = QgsExpression('"owner" || \' — \' || format_number("assessed_value", 0)')
print(expression.dump()) # canonical form, as the parser understood it
print(expression.referencedColumns()) # {'owner', 'assessed_value'}
print(expression.referencedVariables())
print(expression.needsGeometry()) # False — no geometry function used
Breakdown: dump() prints the expression as the parser reconstructed it from the syntax tree, which is the fastest way to spot a quoting mistake — a field reference that was actually parsed as a string literal comes back wrapped in single quotes. referencedColumns() returns the field names the expression will read; comparing it to layer.fields().names() catches typos before the loop starts. needsGeometry() is the one that pays for itself in performance work: when it returns False, you can add the QgsFeatureRequest.NoGeometry flag to the iteration and skip decoding geometry entirely.
That last point turns into a real optimisation on polygon layers, where geometry decoding usually dominates iteration cost:
from qgis.core import QgsFeatureRequest
request = QgsFeatureRequest()
if not expression.needsGeometry():
request.setFlags(QgsFeatureRequest.NoGeometry)
request.setSubsetOfAttributes(expression.referencedColumns(), layer.fields())
for feature in layer.getFeatures(request):
context.setFeature(feature)
print(expression.evaluate(context))
Breakdown: The request is built from the expression's own introspection, so it stays correct if the expression changes. setSubsetOfAttributes() takes the referenced column names directly, fetching only the columns that will actually be read. Combined, the two flags can cut iteration time substantially on a wide table — the same technique used for attribute summaries in Count and Summarise Features by Attribute in PyQGIS.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.28 LTR | 3.9 | Identical API. QgsExpressionContextUtils.globalProjectLayerScopes() available since 3.0. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Same API; the expression function library gained entries but nothing here changed. |
QgsExpression, QgsExpressionContext and the scope helpers have been stable for the whole 3.x series, so this code is portable without modification.
Troubleshooting
- Everything evaluates to
None. The feature scope was never pushed.setFeature()must be called beforeevaluate(), and the layer scope must be present for field names to resolve at all. - A field name reports as unknown. Compare
expression.referencedColumns()againstlayer.fields().names(). A trailing space or a case difference is invisible in the console but fatal to the lookup. - The result is a string when you expected a number. The field is text in the source data. Wrap it with
to_real()in the expression, or fix the field type — see Add a Field to a Layer in PyQGIS. hasParserError()is false but nothing works. The expression is syntactically valid but semantically wrong, most often single quotes used for a field reference. Printexpression.dump()to see how the engine actually parsed it.- Evaluation is slow over a large layer.
prepare()was not called, or the expression was reconstructed inside the loop. Both are hoisting problems, not engine problems.
Conclusion
Evaluating an expression comes down to a fixed sequence: construct, check the parser, build a context with the right scopes, prepare, then set a feature and evaluate inside the loop while checking the evaluation error each time. Keeping the two error checks distinct in your head is what turns expression evaluation from an occasional source of mystery None values into a predictable tool.
Frequently Asked Questions
Why does my expression return None for every feature?
The feature scope is missing. QgsExpressionContext needs the layer scope for field names to exist and setFeature() for their values to be readable. Without both, every field reference resolves to nothing.
What is the difference between hasParserError and hasEvalError?hasParserError() reports syntax problems and is meaningful once, right after construction. hasEvalError() reports data-dependent failures and is reset on every evaluate() call, so it has to be checked per feature.
Do I need to call prepare()? Not for a single evaluation. For a loop it resolves field names once instead of per feature, which is a measurable saving on layers above a few hundred rows. Call it after appending scopes, never before.
Can I evaluate an expression without a layer?
Yes, as long as the expression does not reference fields or geometry. Push only the global and project scopes and evaluate — this is how expressions like format_date(now(), 'yyyyMMdd') are used to build output paths.