Select Features by Expression in PyQGIS

Selecting features by attribute is the scripted equivalent of the "Select by Expression" dialog, and it is the fastest way to turn a rule into a visible, actionable set of rows on the map. selectByExpression() does it in one line — but a selection is a piece of interface state, and treating it as a data-processing tool is where scripts go wrong: selections do not survive a project reload, they interact with what the user has clicked, and iterating them is not the same as filtering.

This page is a focused recipe within Working with QGIS Expressions in PyQGIS. It covers making a selection, combining it with an existing one, reading it back, deciding when a selection is the wrong tool entirely, and feeding a selection into Processing algorithms.

The four SelectBehavior modesAn existing selection of features is shown as a teal set and a new expression match as a blue set. Four panels show the result of each behaviour: SetSelection keeps only the new match, AddToSelection keeps the union, RemoveFromSelection keeps the existing set minus the new match, and IntersectSelection keeps only features in both.The second argument decides what happens to what was already selectedSetSelectionreplaces (default)AddToSelectionthe unionRemoveFromSelectionexisting minus newIntersectSelectionin both setsalready selectedmatched by the expression

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer.
  • A vector layer loaded in the project. Selections only exist on layers registered with QgsProject; a layer opened but never added has nothing to select on.
  • Familiarity with expression quoting — double quotes for fields, single for literals. See Evaluate a QGIS Expression in PyQGIS.

Make a selection

The one-line form replaces whatever was selected before and highlights the matches on the canvas immediately.

from qgis.core import QgsProject

layer = QgsProject.instance().mapLayersByName("parcels")[0]

layer.selectByExpression('"zoning" = \'residential\' AND "assessed_value" > 250000')

print(layer.selectedFeatureCount(), "feature(s) selected")

Breakdown: The expression is an ordinary Python string, so the single quotes that delimit the literal residential in the expression have to be escaped — using a double-quoted Python string with single quotes inside, or a triple-quoted string, avoids the backslashes entirely. selectedFeatureCount() is the cheap way to confirm a match without pulling the features themselves; a count of zero almost always means a quoting mistake rather than genuinely empty data.

For values that come from anywhere other than a literal in your source, build the expression with the quoting helpers rather than an f-string:

from qgis.core import QgsExpression

zoning = user_input          # e.g. "O'Brien Estate"
clause = f'{QgsExpression.quotedColumnRef("zoning")} = {QgsExpression.quotedValue(zoning)}'
layer.selectByExpression(clause)

Breakdown: quotedValue() doubles internal apostrophes the way the expression grammar requires, so a name containing one selects correctly instead of producing a parser error. quotedColumnRef() does the same for the field name. This matters in any plugin where the value originates in a dialog.

Combine with an existing selection

The optional second argument controls how the new match interacts with what is already selected, which is what makes multi-step refinement possible.

from qgis.core import Qgis, QgsProject

layer = QgsProject.instance().mapLayersByName("parcels")[0]

layer.selectByExpression('"zoning" = \'residential\'', Qgis.SelectBehavior.SetSelection)
layer.selectByExpression('"assessed_value" > 500000', Qgis.SelectBehavior.IntersectSelection)
layer.selectByExpression('"status" = \'exempt\'', Qgis.SelectBehavior.RemoveFromSelection)

print(layer.selectedFeatureCount())

Breakdown: Reading down, this selects residential parcels, narrows to the expensive ones, then drops the exempt ones — three simple expressions instead of one long boolean. SetSelection is the default and is what makes the first call a clean starting point regardless of previous state. On QGIS 3.28 and earlier these constants live on QgsVectorLayer (QgsVectorLayer.SetSelection); both spellings work on 3.34, and the Qgis.SelectBehavior form is the one to write in new code.

Read the selection back

A selection is a set of feature IDs, and there are two ways to consume it depending on whether you need the features or just the identifiers.

selected_ids = layer.selectedFeatureIds()
print(len(selected_ids), "id(s)")

for feature in layer.getSelectedFeatures():
    print(feature["parcel_id"], feature["assessed_value"])

total = sum(f["assessed_value"] or 0 for f in layer.getSelectedFeatures())
print(f"total assessed value: {total:,.0f}")

Breakdown: selectedFeatureIds() returns just the IDs and is nearly free — use it when you only need a count, a membership test, or something to pass to another call. getSelectedFeatures() returns an iterator of full features and reads from the provider, so it costs the same as any other iteration. The or 0 guard handles NULL values, which would otherwise make the sum() raise on the first empty cell.

When a selection is the wrong tool

A selection is user-interface state. It is visible, it is what the user sees highlighted, and it does not persist. For pure data processing — reading rows that match a rule — a feature request is faster, does not disturb what the user has selected, and works on layers that are not in the project at all.

Select for the user, request for the scriptOn the left, selectByExpression highlights matching parcels on the canvas, changes what the user sees, and is cleared when the project reloads. On the right, a QgsFeatureRequest with a filter expression streams only the matching rows into the script, leaves the canvas untouched, and pushes the predicate down to the data provider.One changes what the user sees; the other does notselectByExpression()highlighted on the canvasevery feature still readlost on project reloadQgsFeatureRequestcanvas untouchedmatchingrows onlypredicate pushed to the providerworks on unregistered layers

from qgis.core import QgsFeatureRequest

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: The filter is translated into SQL for providers that support it — PostGIS and GeoPackage among them — so the database does the matching and only the matching rows are transferred. selectByExpression(), by contrast, must evaluate the expression against every feature to decide what to highlight. Adding setSubsetOfAttributes() narrows the columns as well. Use a selection when a human needs to see the result; use a request when only the script does.

Feed a selection into Processing

When a selection is the right starting point — because the user made it — Processing algorithms can operate on just those features rather than the whole layer.

import processing
from qgis.core import QgsProcessingFeatureSourceDefinition

source = QgsProcessingFeatureSourceDefinition(layer.id(), selectedFeaturesOnly=True)

result = processing.run("native:buffer", {
    "INPUT": source,
    "DISTANCE": 50,
    "OUTPUT": "TEMPORARY_OUTPUT",
})["OUTPUT"]

print(result.featureCount(), "buffered feature(s)")

Breakdown: QgsProcessingFeatureSourceDefinition wraps a layer ID with the selectedFeaturesOnly flag, which is how the GUI's "Selected features only" checkbox is expressed in a script. Passing the layer object directly instead would silently process everything. This composes with the pipelines in Chaining Processing Algorithms in PyQGIS, where a user-driven selection often defines the study area for a whole chain.

Select across several layers at once

A study area is rarely one layer. Applying the same rule across every layer that has the relevant field — and reporting which ones did not — turns a repetitive manual job into four lines.

from qgis.core import QgsProject

RULE = '"status" = \'active\''
report = {}

for layer in QgsProject.instance().mapLayers().values():
    if layer.type() != layer.VectorLayer:
        continue
    if layer.fields().indexOf("status") == -1:
        report[layer.name()] = "no status field"
        continue
    layer.selectByExpression(RULE)
    report[layer.name()] = f"{layer.selectedFeatureCount()} selected"

for name, outcome in sorted(report.items()):
    print(f"{name:<28} {outcome}")

Breakdown: mapLayers() returns every layer in the project keyed by ID, including rasters, so the type check comes first — calling selectByExpression() on a raster raises. Testing indexOf("status") == -1 before selecting distinguishes "no matching rows" from "this layer does not have that column", which are very different findings and look identical in a bare count. Building a report dictionary rather than printing inside the loop keeps the output ordered and makes the whole thing easy to return from a function.

Applying one rule across every layer in a projectA single status expression fans out to four layers. Parcels and buildings each report a count of selected features. Roads reports zero matches, which is a valid result. Hillshade is skipped because it is a raster and has no attribute table, and a fourth vector layer is skipped because it has no status field.Distinguish "nothing matched" from "this layer cannot match""status" = 'active'one ruleparcels412 selectedbuildings87 selectedroads0 selectedhillshadeskipped — rastera real resultalso a real resultnot comparable

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.28 LTR3.9Use QgsVectorLayer.SetSelection etc. rather than Qgis.SelectBehavior.
3.34 LTR3.12Baseline. Both enum spellings accepted.
3.40 / 3.443.12Qgis.SelectBehavior is the documented form; the old constants still resolve.

selectByExpression(), selectedFeatureIds() and getSelectedFeatures() are unchanged across the whole 3.x line.

Troubleshooting

  • Zero features selected but the data clearly matches. A quoting error. "zoning" = "residential" compares two field references; 'zoning' = 'residential' compares two literals and is always false. Only the mixed form is right.
  • The selection is empty after a Processing run. Some algorithms replace the layer rather than editing it. Re-select on the output layer, not the input.
  • selectByExpression raises about an unknown field. The field name has a typo or the layer is not the one you think it is. Print layer.fields().names() and layer.name() together.
  • The selection does not appear on the canvas. The layer is not registered with the project, or its visibility is off. Selections are drawn as part of the layer's render.
  • The user's selection disappears when your script runs. You used SetSelection where AddToSelection was meant, or you should not be selecting at all — use a feature request instead.
  • A Processing algorithm ignores the selection. The input was passed as a layer object. Wrap it in QgsProcessingFeatureSourceDefinition(layer.id(), selectedFeaturesOnly=True).

Conclusion

selectByExpression() is the right tool when a person needs to see the result: it is one line, it composes through the four selection behaviours, and it feeds Processing cleanly through QgsProcessingFeatureSourceDefinition. When only the script consumes the rows, reach for a QgsFeatureRequest instead — it is faster, it pushes work to the provider, and it leaves the user's own selection alone.

Frequently Asked Questions

Why does my selection expression match nothing? Almost always quoting. Field names take double quotes and string values take single quotes; getting either wrong produces a valid expression that is never true rather than an error. Verify with layer.selectedFeatureCount() immediately after the call.

How do I add to a selection instead of replacing it? Pass Qgis.SelectBehavior.AddToSelection as the second argument. SetSelection is the default, which is why a second call normally appears to discard the first result.

Is selecting features faster than filtering them? No — it is slower for pure data work. A selection evaluates the expression against every feature and updates interface state. A QgsFeatureRequest with setFilterExpression() lets the provider do the matching and returns only the rows you want.

Does a selection persist when the project is saved? No. Selections are transient interface state and are empty again after a reload. If a set of features needs to persist, write a flag to an attribute or export them to their own layer.