Filtering Features with QgsFeatureRequest Expressions
Push attribute conditions down to the data provider with QgsFeatureRequest.setFilterExpression: what compiles to SQL, how to combine it with attribute…
TL;DR: put the condition in QgsFeatureRequest.setFilterExpression() rather than in a Python if, so the data provider evaluates it — often using its own index — and only matching features ever cross into Python. This page is part of the attribute data and the expression engine guide.
Complete Runnable Code
"""Select features with a provider-side expression filter."""
from typing import Iterator
from qgis.core import QgsFeature, QgsFeatureRequest, QgsRectangle, QgsVectorLayer
def select_features(layer: QgsVectorLayer, expression: str,
fields: list[str] | None = None,
extent: QgsRectangle | None = None,
with_geometry: bool = True) -> Iterator[QgsFeature]:
"""Yield features matching `expression`, reading as little as possible.
Every argument narrows what the provider has to produce: the expression
selects rows, `fields` selects columns, `extent` adds a spatial pre-filter,
and `with_geometry=False` skips geometry decoding entirely.
"""
if not layer.isValid():
raise ValueError("layer %r is not valid" % layer.name())
request = QgsFeatureRequest().setFilterExpression(expression)
if fields is not None:
request.setSubsetOfAttributes(fields, layer.fields())
if extent is not None:
request.setFilterRect(extent)
if not with_geometry:
request.setNoGeometry()
yield from layer.getFeatures(request)
def count_matching(layer: QgsVectorLayer, expression: str) -> int:
"""Count matches without materialising anything — no geometry, no attributes."""
request = (QgsFeatureRequest()
.setFilterExpression(expression)
.setNoGeometry()
.setSubsetOfAttributes([]))
return sum(1 for _ in layer.getFeatures(request))
Architecture Breakdown
setFilterExpression() — a request, not a loop
The expression string is handed to the provider, which compiles as much of it as its own query language can express. On PostGIS that becomes a WHERE clause evaluated by the database; on GeoPackage it becomes a SQLite predicate. Rows that do not match are never read, never converted into QgsFeature objects, and never seen by Python.
What the provider cannot compile, QGIS evaluates itself after reading each row. The call is therefore always correct, and its cost depends on which half of that boundary your expression falls on.
setSubsetOfAttributes() — the columns, not the rows
A filter narrows rows; an attribute subset narrows columns. They are independent, and on wide tables the second is worth as much as the first. Pass field names with the layer’s fields() object, which is less error-prone than the index-based overload.
Requesting no attributes at all is legitimate and fast when you only need a count or a set of identifiers.
Combining with a rectangle filter
setFilterRect() adds a spatial pre-filter that indexed providers answer from their own R-tree. Combining it with an expression is the cheapest way to ask a question that is both spatial and attribute-based, because the two filters compose inside the provider rather than in Python.
Order does not matter in the API — the provider decides how to apply them — but the effect is multiplicative, which is why the combined case above is so much faster than either alone.
Integrating It Into a Plugin
from qgis.core import QgsProject, QgsRectangle
def selected_parcel_ids(layer_name: str, canvas_extent: QgsRectangle) -> list[int]:
"""Return the ids of active parcels visible in the current canvas extent."""
layers = QgsProject.instance().mapLayersByName(layer_name)
if not layers:
raise LookupError("no layer named %r in the project" % layer_name)
request_expression = "\"status\" = 'ACTIVE' AND \"area_m2\" > 500"
return [f.id() for f in select_features(layers[0], request_expression,
fields=[], extent=canvas_extent,
with_geometry=False)]
Note the doubled quotes: in QGIS expressions, double quotes denote a field and single quotes a string literal. Swapping them produces an expression that parses cleanly and matches nothing.
Reading the Cost of a Filter
The two questions worth asking about any filter are how much of it the provider could compile and how much data survives it. The first decides whether the layer is scanned; the second decides how much crosses into Python.
A filter of pure field comparisons on an indexed provider is close to free — the database uses an index, and the cost tracks the number of matches rather than the size of the table. Add a geometric function such as $area and the same filter becomes a full scan with a measurement per row, because nothing in that expression can be expressed in SQL. The filter is still correct, and on a small layer the difference is invisible; on a few million rows it is the difference between a hundred milliseconds and a minute.
The practical consequence is that stored columns beat computed ones for anything that filters at scale. A pipeline that writes area_m2 once and filters on it will outperform one that computes $area on every query, and it will do so by a margin that grows with the dataset.
One more thing is worth measuring rather than assuming: how selective the filter actually is. A condition that keeps ninety per cent of the rows costs almost as much as no filter at all, and the constraint that helps in that case is the attribute subset rather than the predicate. Knowing which of the two you need takes one count query and saves a great deal of guessing.
Production Best Practices
- Never filter in Python when the provider can do it. The difference is not a constant factor; it grows with the size of the layer.
- Use
"field"for fields and'value'for strings. This is the single most common source of filters that silently match nothing. - Handle NULL explicitly.
"status" <> 'CLOSED'excludes rows where status is NULL, because the comparison yields NULL rather than true. - Avoid
$areaand$lengthin filters over large layers. They cannot be compiled, so every row is read and measured. - Validate the expression before using it when it comes from user input, with
QgsExpression(text).hasParserError(). - Prefer
setSubsetOfAttributes([])tosetNoAttributes()when you want to be explicit about which columns you need; both are correct, the first reads better next to a field list.
Frequently Asked Questions
Does the filter run in the database or in QGIS?
Both, potentially — the provider compiles what it can and QGIS evaluates the rest. You can see which happened by timing a filter that uses only field comparisons against one that uses $area: the first scales with the number of matches, the second with the size of the layer.
Why does my filter match nothing when the same text works in the GUI?
Almost always quoting. The attribute table’s filter box is forgiving about single and double quotes in ways the expression engine is not, and a field name in single quotes becomes a string literal that never equals itself.
Can I filter on a joined or virtual field?
Virtual fields work but are never compiled, so the whole layer is read. Fields from a layer join are evaluated by QGIS for the same reason. If a filter on such a field is hot, materialise the value into a real column first.
Is there a limit to how complex the expression can be?
Not a practical one, but complexity shifts work from the provider to QGIS. Each construct the provider cannot compile forces the whole predicate to be evaluated after the row is read, so one awkward clause can undo the benefit of five compilable ones.
When a filter is both complex and hot, splitting it is often faster: use a request expression for the part that compiles, and test the remainder in Python on the much smaller surviving set.
Does the filter apply to a selection?
No — a request filter and the layer’s selection are independent. getFeatures(request) returns matching features whether or not they are selected, and selectByExpression() changes the selection without returning anything. Mixing them up produces code that appears to work interactively, because the user happens to have selected the right things, and then behaves differently in a headless run where nothing is selected at all.
When you specifically want the user’s selection, iterate layer.selectedFeatures() or pass QgsFeatureRequest().setFilterFids(layer.selectedFeatureIds()), which keeps the rest of the request machinery available.
How do I filter by feature id?
Use setFilterFids() with a list of identifiers rather than writing $id IN (...). The dedicated call is understood by every provider and is the fastest path to a known set of rows, which is exactly what a spatial index lookup returns.
Note that feature ids are not stable across providers or across a rewrite of the file. They are a handle for the duration of a session, not a key to store.
Related
- Attribute Data and the Expression Engine — the parent guide covering context and evaluation
- Optimizing Feature Iteration with QgsVectorLayer.getFeatures — every other constraint a request can carry
- QgsSpatialIndex vs setFilterRect — when the spatial half of the filter needs an index of its own