Working with QGIS Expressions in PyQGIS
The QGIS expression engine is the small language that appears everywhere in the application: the field calculator, the feature filter box, rule-based renderers, labelling rules, layout item text, and every data-defined override. It is not a Python subset — it is its own evaluator, written in C++, with its own function library and its own idea of what NULL means. Learning to drive it from PyQGIS is one of the highest-leverage skills in the whole API, because a single expression can replace a hand-written feature loop that would otherwise be twenty lines slower and less correct.
This guide sits inside PyQGIS Fundamentals & Environment Setup because expressions are infrastructure: once you understand the evaluation model, everything from programmatic layer styling to rule-based labelling becomes a matter of writing the right expression string rather than reinventing the logic in Python. It covers how the engine resolves names, how to evaluate an expression against a feature, how to filter and select with one, how expressions drive styling through data-defined properties, and how to extend the function library with your own Python functions.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer. The expression classes have been stable across the whole 3.x line; version-specific notes are called out inline.
- Access to PyQGIS through the Python Console, a standalone script, or a plugin — see QGIS Python Console Basics if you have not run PyQGIS before.
- A loaded vector layer with at least one numeric and one text field to experiment against.
- Familiarity with the QGIS API architecture, in particular the split between
qgis.coreandqgis.gui.
Everything in this guide lives in qgis.core, so it works identically in a headless script started outside QGIS Desktop — see Running Python Scripts Outside QGIS Desktop.
How the expression engine resolves names
An expression string on its own means nothing. When you write "population" / $area > 100, the engine has to decide what "population" refers to, what $area means, and whether a function called round exists. It answers those questions from a QgsExpressionContext, which is a stack of scopes. Each scope contributes variables and functions, and later scopes shadow earlier ones — exactly like nested Python namespaces.
The conventional stack, from lowest to highest priority, is: global scope (QGIS-wide variables such as @qgis_version), project scope (@project_title and anything set in Project Properties), layer scope (@layer_name, @layer_id), and finally the feature scope, which contributes the current feature's field values and $geometry. Because the feature scope sits on top, a field named layer_name would shadow the layer variable — a subtle collision worth knowing about when you inherit someone else's schema.
Building that stack by hand is three lines, and QgsExpressionContextUtils provides the standard scopes so you never have to remember what belongs in each:
from qgis.core import (
QgsExpression,
QgsExpressionContext,
QgsExpressionContextUtils,
QgsProject,
)
layer = QgsProject.instance().mapLayersByName("parcels")[0]
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
Breakdown: globalProjectLayerScopes() returns the global, project, and layer scopes already populated and in the right order. appendScopes() pushes them onto the stack. What is deliberately missing is the feature scope — you add that per feature, because it changes on every iteration.
Evaluating an expression against a feature
With a context in hand, evaluation is a two-step dance: parse once, then set the feature and evaluate repeatedly. Parsing is the expensive half, so hoisting it out of the loop matters on large layers.
from qgis.core import (
QgsExpression,
QgsExpressionContext,
QgsExpressionContextUtils,
QgsProject,
)
layer = QgsProject.instance().mapLayersByName("parcels")[0]
expression = QgsExpression('"assessed_value" / area($geometry)')
if expression.hasParserError():
raise ValueError(expression.parserErrorString())
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
expression.prepare(context)
for feature in layer.getFeatures():
context.setFeature(feature)
value = expression.evaluate(context)
if expression.hasEvalError():
print(f"feature {feature.id()}: {expression.evalErrorString()}")
continue
print(f"feature {feature.id()}: {value}")
Breakdown: hasParserError() catches syntax problems before the loop — a misspelled function name or an unbalanced quote fails here, not per feature. prepare() lets the engine resolve field indexes once against the context, which measurably speeds up long iterations. setFeature() swaps the feature scope in place. hasEvalError() is separate from the parser error and must be checked after each evaluate(), because a valid expression can still fail on a particular row — dividing by a zero area, for example.
Two details trip people up. Field references use double quotes ("assessed_value"); single quotes denote a string literal, so 'assessed_value' evaluates to the literal text rather than the column. And the return value is a Python object converted from a QVariant: numbers arrive as int or float, text as str, and SQL NULL as None. The dedicated recipe Evaluate a QGIS Expression in PyQGIS works through the error-handling patterns in more depth.
Evaluating without a feature
Not every expression needs a row. Expressions that only reference variables or constants — @qgis_version, now(), 1 + 1 — evaluate against a bare context, which makes them handy for building dynamic output paths in batch processing pipelines:
from qgis.core import QgsExpression, QgsExpressionContext, QgsExpressionContextUtils
context = QgsExpressionContext()
context.appendScopes([QgsExpressionContextUtils.globalScope(),
QgsExpressionContextUtils.projectScope(QgsProject.instance())])
stamp = QgsExpression("format_date(now(), 'yyyyMMdd')").evaluate(context)
print(f"/data/exports/run_{stamp}.gpkg")
Breakdown: Only the global and project scopes are pushed, because there is no layer or feature involved. format_date() is an expression-engine function, not a Python one — the whole point is that the same string would work in the layout item that stamps the map.
Filtering and selecting features with an expression
The single biggest performance win from expressions is pushing the filter down to the data provider instead of testing every feature in Python. QgsFeatureRequest.setFilterExpression() does exactly that: for a PostGIS or GeoPackage layer the predicate is translated into SQL and executed by the database, so only matching rows ever cross into Python.
from qgis.core import QgsFeatureRequest, QgsProject
layer = QgsProject.instance().mapLayersByName("parcels")[0]
request = QgsFeatureRequest()
request.setFilterExpression('"zoning" = \'residential\' AND "assessed_value" > 250000')
request.setSubsetOfAttributes(["parcel_id", "assessed_value"], layer.fields())
for feature in layer.getFeatures(request):
print(feature["parcel_id"], feature["assessed_value"])
Breakdown: setFilterExpression() takes the same string the GUI filter box accepts. setSubsetOfAttributes() narrows the columns fetched, which is a second, independent saving — combined, the two turn a full-table scan into a targeted query. Note the escaping: the whole expression is a Python string, so the single quotes around residential are backslash-escaped.
To highlight the matches on the map rather than iterate them, selectByExpression() is the one-liner equivalent, and it is the basis of Select Features by Expression in PyQGIS. Both approaches beat the naive alternative — reading every feature and testing attributes in a Python if — by a wide margin on anything larger than a few thousand rows.
Expressions that drive styling
Data-defined properties are where expressions stop being a query language and become a design tool. Any symbol property that accepts an override — size, colour, rotation, opacity, offset — can be bound to a QgsProperty built from an expression, and QGIS re-evaluates it per feature at render time. Nothing is stored on the layer; the styling simply becomes a function of the data.
from qgis.core import QgsProperty, QgsProject, QgsSymbolLayer
layer = QgsProject.instance().mapLayersByName("towns")[0]
symbol_layer = layer.renderer().symbol().symbolLayer(0)
symbol_layer.setDataDefinedProperty(
QgsSymbolLayer.PropertySize,
QgsProperty.fromExpression('scale_linear("population", 0, 100000, 2, 12)'),
)
layer.triggerRepaint()
Breakdown: QgsSymbolLayer.PropertySize names the property being overridden; the enum has a member for every overridable attribute. QgsProperty.fromExpression() wraps the expression string. scale_linear() maps an input range onto an output range, which is the idiomatic way to size symbols proportionally without writing a classification. triggerRepaint() is what makes the canvas pick the change up — without it the layer keeps drawing from its cached render.
The same mechanism drives colour (PropertyFillColor with a ramp_color() expression), rotation, and label placement. Set a Vector Layer's Symbol Colour in PyQGIS covers the static case; Data-Defined Symbol Size with an Expression works through the dynamic one, including how to keep the legend readable.
Registering your own expression functions
When an expression gets long enough to be unreadable, or needs logic the built-in library cannot express, you can add a function to the engine in Python. The @qgsfunction decorator registers it globally, and from that moment it appears in the expression builder dialog for users as well as in your scripts.
from qgis.core import qgsfunction
@qgsfunction(args="auto", group="Custom", referenced_columns=[])
def utm_zone(longitude, feature, parent):
"""Return the UTM zone number containing the given longitude."""
return int((longitude + 180) / 6) + 1
Breakdown: args="auto" tells QGIS to infer the argument count from the signature — everything before the mandatory feature and parent parameters. group decides which folder the function appears under in the expression builder. referenced_columns=[] declares that the function reads no fields directly, which lets the provider keep optimising attribute fetching; omit it and QGIS conservatively loads every column. The docstring becomes the help text shown in the dialog.
Once registered, utm_zone(x_min($geometry)) works anywhere an expression works — field calculator, filters, labels. The lifecycle detail that catches people out is unregistration: a plugin that registers functions in initGui() must call QgsExpression.unregisterFunction() in unload(), or reloading the plugin raises a duplicate-name error. Register a Custom Expression Function in PyQGIS covers that lifecycle in full, and Reload a QGIS Plugin Without Restarting explains why it bites during development.
Debugging expressions that return NULL
A silent None is the most common expression complaint, and it almost always has one of four causes. Working through them in order resolves nearly every case.
A field name is wrong or unquoted. "popluation" is a typo the parser cannot catch, because an unknown name is only detected at evaluation time against a specific feature. Compare expression.referencedColumns() against layer.fields().names() to catch it programmatically.
A single quote was used where a double quote was meant. 'zoning' = 'residential' compares two literals and is always false. The parser accepts it happily.
NULL propagates. In the expression engine, any arithmetic involving NULL yields NULL — "a" + "b" is NULL if either is empty. Wrap the operands with coalesce("a", 0) when an empty cell should behave as a zero.
The geometry is missing. $area, $length, and area($geometry) return NULL for a feature with no geometry, which is common after a failed join or an import from CSV. Guard with is_empty_or_null($geometry).
A compact diagnostic that reports all four at once:
from qgis.core import QgsExpression, QgsExpressionContext, QgsExpressionContextUtils
def diagnose(layer, expression_text):
expression = QgsExpression(expression_text)
if expression.hasParserError():
return f"parser error: {expression.parserErrorString()}"
unknown = set(expression.referencedColumns()) - set(layer.fields().names())
unknown.discard(QgsExpression.ALL_ATTRIBUTES)
if unknown:
return f"unknown field(s): {', '.join(sorted(unknown))}"
context = QgsExpressionContext()
context.appendScopes(QgsExpressionContextUtils.globalProjectLayerScopes(layer))
feature = next(layer.getFeatures(), None)
if feature is None:
return "layer has no features to test against"
context.setFeature(feature)
value = expression.evaluate(context)
if expression.hasEvalError():
return f"eval error: {expression.evalErrorString()}"
return f"first feature evaluates to: {value!r}"
Breakdown: referencedColumns() returns the field names the expression mentions, so a set difference against the layer's real fields catches typos instantly. ALL_ATTRIBUTES is a sentinel the engine returns when an expression could touch anything (for example when it calls a function with unknown column references) — discarding it prevents a false positive. Evaluating against the first feature turns an abstract "returns NULL" into a concrete, reproducible value. For the broader toolkit, see Debugging PyQGIS Scripts.
Choosing between an expression and a Python loop
Expressions are not always the right answer, and reaching for one reflexively can make a script harder to read than the loop it replaced. The decision comes down to where the work has to happen and how much state it needs.
| Situation | Reach for | Why |
|---|---|---|
| Rule-based renderer, label rule, filter box | Expression | These components accept nothing else — the logic has to be a string |
| Filtering a large layer before iterating | Expression via QgsFeatureRequest | The predicate is translated to SQL and executed by the provider |
| Running totals, ranking, comparing feature n to n-1 | Python loop | The engine evaluates each feature independently and keeps no state |
| Calling an external library or web service | Python loop, or a custom function | The expression library has no equivalent, though @qgsfunction can bridge one in |
| Simple derived column written back to the layer | Either | An expression in a field calculator call is shorter; a loop is easier to unit test |
The middle row is the one worth internalising: the expression engine has no memory between features. Anything that needs to compare a feature to the one before it, accumulate a total, or assign a rank belongs in Python — see Update Attribute Values in Bulk with PyQGIS for the editing patterns that go with that.
Building expression strings safely
Most real scripts assemble expressions from values that came from somewhere else — a dialog field, a config file, a list of category names. Concatenating those into a string with an f-string works right up until a value contains an apostrophe, at which point the expression stops parsing and, in a plugin exposed to user input, becomes an injection vector into whatever the expression touches.
QgsExpression provides quoting helpers for exactly this, and using them is both shorter and correct:
from qgis.core import QgsExpression
field = "owner_name"
wanted = ["O'Brien Holdings", "Ash & Co"]
clause = " OR ".join(
f"{QgsExpression.quotedColumnRef(field)} = {QgsExpression.quotedValue(name)}"
for name in wanted
)
print(clause)
# "owner_name" = 'O''Brien Holdings' OR "owner_name" = 'Ash & Co'
Breakdown: quotedColumnRef() wraps a name in double quotes and escapes any embedded ones, producing a valid field reference even for a column called my "odd" name. quotedValue() does the same for a literal, doubling internal apostrophes the way the expression grammar requires and rendering None as the bare keyword NULL. Building the clause with a generator and join() keeps it readable regardless of how many values arrive.
For an IN list the same helper applies, and the resulting expression is usually faster than a chain of ORs because the provider can translate it into a single SQL predicate:
values = ", ".join(QgsExpression.quotedValue(name) for name in wanted)
expression = f'{QgsExpression.quotedColumnRef(field)} IN ({values})'
Breakdown: One IN clause replaces n comparisons, and PostGIS in particular optimises it well. The rule of thumb is simple: never format a user-supplied value into an expression yourself — always route it through quotedValue().
Key takeaways
- Expressions are their own language, not Python. Double quotes reference fields, single quotes are string literals, and
NULLpropagates through arithmetic rather than raising. - A context is a stack of scopes.
QgsExpressionContextUtils.globalProjectLayerScopes()builds the standard three; add the feature scope yourself withsetFeature()inside the loop. - Parse once, evaluate many. Construct
QgsExpressionand callprepare()outside the loop, then checkhasEvalError()after each evaluation — parser errors and evaluation errors are separate conditions. - Push filters into the provider.
QgsFeatureRequest.setFilterExpression()lets a database do the work; combining it withsetSubsetOfAttributes()avoids fetching columns you will not read. - Data-defined properties turn styling into a function of the data.
QgsProperty.fromExpression()bound to aQgsSymbolLayerproperty re-evaluates per feature at render time and stores nothing on the layer. - Custom functions must be unregistered. A plugin that registers with
@qgsfunctionhas to callQgsExpression.unregisterFunction()on unload or a reload will fail.
Frequently Asked Questions
Why does my expression return NULL instead of a number?
The four usual causes are a misspelled field name, single quotes where double quotes were meant, NULL propagating through arithmetic, and a missing geometry. Compare expression.referencedColumns() with layer.fields().names() to catch the first two, and wrap operands in coalesce() to neutralise the third.
What is the difference between single and double quotes in a QGIS expression?
Double quotes reference a field — "population" reads that column. Single quotes create a string literal — 'population' is the eleven-character word. Because both are syntactically valid, mixing them up produces a comparison that is silently always false rather than an error.
Do I need a QgsExpressionContext to evaluate an expression?
Only if the expression references fields, variables, or the geometry. A self-contained expression such as 2 + 2 evaluates without one, but anything touching @project_title or a column needs the matching scope pushed onto the context first.
Is filtering with setFilterExpression faster than looping in Python? Substantially, on any provider that can translate the predicate. For PostGIS and GeoPackage layers the filter becomes SQL executed by the database, so only matching rows are transferred. A Python loop always fetches every feature first.
Can I use a custom Python function inside a data-defined property?
Yes. Once registered with @qgsfunction, the function is part of the expression engine and available anywhere an expression is accepted, including data-defined overrides and layout item text. Make sure the plugin registering it also unregisters it on unload.
Related
- PyQGIS Fundamentals & Environment Setup — the guide this page belongs to
- QGIS API Architecture
- QGIS Python Console Basics
- Debugging PyQGIS Scripts
- Evaluate a QGIS Expression in PyQGIS
- Select Features by Expression in PyQGIS
- Data-Defined Symbol Size with an Expression in PyQGIS
- Register a Custom Expression Function in PyQGIS
- Attribute Tables and Field Management in PyQGIS
- Programmatic Layer Styling in PyQGIS