Highlight a Feature with a Rubber Band in PyQGIS
A rubber band is the canvas's scratch layer: an overlay drawn on top of everything, owned by the canvas scene, holding no data and costing nothing to redraw. It is what QGIS itself uses for the selection halo, the measure line and the digitising preview, and it is the right answer whenever you want to point at something without altering the project.
The mistake is treating it like a layer. A rubber band added and forgotten stays on the canvas for the rest of the session — it is not in the layer panel, so the user cannot remove it, and it survives every zoom and pan.
This page is a focused recipe within Custom Map Tools and Canvas Interaction. It covers highlighting a feature's real geometry, styling the band, handling CRS differences, a flash animation, and the cleanup that keeps the canvas honest.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer, running as a desktop application.
- A plugin or a Python Console session with
ifaceavailable. - A vector layer with features to highlight.
Highlight a feature's geometry
setToGeometry() takes the feature's real geometry, so the highlight follows its exact outline rather than a bounding box.
from qgis.core import QgsProject, QgsWkbTypes
from qgis.gui import QgsRubberBand
from qgis.PyQt.QtGui import QColor
canvas = iface.mapCanvas()
layer = QgsProject.instance().mapLayersByName("parcels")[0]
feature = next(layer.getFeatures())
band = QgsRubberBand(canvas, QgsWkbTypes.PolygonGeometry)
band.setColor(QColor(180, 83, 9, 70))
band.setStrokeColor(QColor(180, 83, 9))
band.setWidth(3)
band.setToGeometry(feature.geometry(), layer)
Breakdown: The geometry type in the constructor decides how the band draws — PolygonGeometry fills, LineGeometry strokes only, PointGeometry draws vertex markers. The fourth argument to the fill QColor is alpha; 70 out of 255 gives a translucent wash that highlights without hiding the map underneath. Passing layer as the second argument to setToGeometry() is what handles the CRS — the band lives in canvas coordinates, and that argument tells it which system the geometry is in so it can transform. Omitting it, or passing None, assumes the geometry is already in the canvas CRS, which silently puts the highlight in the wrong place whenever the project and layer differ.
Highlight several features
A single band can hold multiple geometries, which is both faster and easier to clean up than one band per feature.
from qgis.core import QgsWkbTypes
band = QgsRubberBand(canvas, QgsWkbTypes.PolygonGeometry)
band.setColor(QColor(15, 118, 110, 60))
band.setStrokeColor(QColor(15, 118, 110))
band.setWidth(2)
for feature in layer.getSelectedFeatures():
band.addGeometry(feature.geometry(), layer)
canvas.refresh()
Breakdown: addGeometry() appends rather than replacing, so each call adds another part to the same band — again taking the layer for the CRS transform. One band with fifty geometries repaints as a single overlay; fifty bands repaint fifty times and each must be tracked and reset individually. setToGeometry() is effectively reset() followed by addGeometry(), which is why it replaces rather than accumulates.
Flash rather than persist
A permanent highlight competes with the layer's own styling. A brief flash draws the eye and then gets out of the way, which is what QGIS's own "flash feature" does.
from qgis.PyQt.QtCore import QTimer
def flash(canvas, geometry, layer, times=3, interval=180):
band = QgsRubberBand(canvas, QgsWkbTypes.PolygonGeometry)
band.setColor(QColor(220, 38, 38, 90))
band.setStrokeColor(QColor(185, 28, 28))
band.setWidth(3)
state = {"count": 0, "visible": False}
def tick():
state["visible"] = not state["visible"]
if state["visible"]:
band.setToGeometry(geometry, layer)
else:
band.reset(QgsWkbTypes.PolygonGeometry)
state["count"] += 1
if state["count"] >= times:
timer.stop()
band.reset(QgsWkbTypes.PolygonGeometry)
timer = QTimer(canvas)
timer.timeout.connect(tick)
timer.start(interval)
return timer
Breakdown: Parenting the QTimer to the canvas means Qt destroys it if the canvas goes away, which prevents a timer firing into a dead object. Returning the timer lets the caller stop it early — and keeps a reference alive, without which the timer would be collected and never fire. The final reset() inside the stop branch is what guarantees nothing is left behind even if the count arithmetic changes later. QGIS also offers canvas.flashGeometries() for exactly this, which is worth preferring when the default styling is acceptable.
Style, and clean up
from qgis.core import QgsWkbTypes
from qgis.PyQt.QtCore import Qt
band.setLineStyle(Qt.DashLine)
band.setIconSize(9)
band.setIcon(QgsRubberBand.ICON_BOX)
# ... later, always:
band.reset(QgsWkbTypes.PolygonGeometry)
Breakdown: setLineStyle() takes a Qt pen style — Qt.DashLine reads as provisional, which is the right signal for a proposed change the user has not confirmed. setIcon() and setIconSize() control the vertex markers, which only appear on point and line bands. reset() clears the band's geometry and takes the geometry type, because a band can be reused for a different type after clearing.
That final reset() is not optional. Call it in the tool's deactivate(), in the plugin's unload(), and anywhere the highlight logically ends — a band whose owning object is garbage collected is still drawn by the canvas scene, so there is no automatic cleanup to fall back on.
Keep one band per purpose
A plugin that highlights several different things — a hovered feature, a confirmed selection, a validation error — needs to be able to clear one without disturbing the others. Holding a named band per role makes that trivial and prevents the accumulation of orphaned overlays.
"hover"
from qgis.core import QgsWkbTypes
from qgis.gui import QgsRubberBand
from qgis.PyQt.QtGui import QColor
ROLES = {
"hover": (QColor(37, 99, 235, 50), QColor(37, 99, 235), 2),
"selected": (QColor(15, 118, 110, 70), QColor(15, 118, 110), 3),
"error": (QColor(185, 28, 28, 60), QColor(185, 28, 28), 3),
}
class Highlighter:
def __init__(self, canvas):
self.canvas = canvas
self.bands = {}
for role, (fill, stroke, width) in ROLES.items():
band = QgsRubberBand(canvas, QgsWkbTypes.PolygonGeometry)
band.setColor(fill)
band.setStrokeColor(stroke)
band.setWidth(width)
self.bands[role] = band
def show(self, role, geometry, layer):
self.bands[role].setToGeometry(geometry, layer)
def clear(self, role=None):
targets = self.bands.values() if role is None else [self.bands[role]]
for band in targets:
band.reset(QgsWkbTypes.PolygonGeometry)
Breakdown: Creating every band up front and reusing them avoids the churn of constructing and destroying overlays as the cursor moves. Declaring the colours in one ROLES table keeps the visual language consistent, which matters because users learn what each colour means. clear() with no argument clearing everything is what unload() and deactivate() call, while clear("hover") handles the common case of the cursor leaving a feature — a single shared band could not do both.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.28 LTR | 3.9 | Identical API. canvas.flashGeometries() available since 3.6. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Qgis.GeometryType replaces QgsWkbTypes.PolygonGeometry; the old spelling still resolves. |
QgsRubberBand and its styling methods are unchanged across the 3.x line.
Troubleshooting
- The highlight is in the wrong place. The layer was not passed to
setToGeometry()oraddGeometry(), so no CRS transform was applied. - The band never disappears.
reset()was not called. Nothing removes a rubber band automatically, not even collecting the Python object. - The highlight hides the feature. The fill alpha is too high. Values around 60–90 out of 255 read as a highlight rather than a mask.
- Nothing is drawn. The geometry is null or empty, or the band's geometry type does not match — a polygon added to a
LineGeometryband draws only its outline. - Dragging is jerky. Every
addPoint()is repainting. PassFalsefor the update argument on intermediate points and repaint once at the end. - The flash never fires. The
QTimerwas created as a local and collected. Keep a reference, and parent it to the canvas.
Conclusion
A rubber band is an overlay, not a layer: it costs nothing, it draws above everything, and it is entirely your responsibility to remove. Pass the layer to setToGeometry() so the CRS is handled, prefer one band holding many geometries over many bands, and call reset() in every path that ends the highlight.
Frequently Asked Questions
Why is my rubber band in the wrong place on the map?
The geometry's CRS was not declared. Pass the layer as the second argument to setToGeometry() or addGeometry() so the band transforms the coordinates into the canvas CRS.
How do I remove a rubber band?
Call band.reset(geometry_type). Nothing removes it automatically — the canvas scene owns the drawing, so even letting the Python object go out of scope leaves the highlight visible.
Should I use a rubber band or a memory layer? A rubber band for transient feedback that the user should not manage, and a memory layer when the highlight needs to appear in the layer panel, be toggled, or be included in an export.
How do I highlight several features at once?
Create one band and call addGeometry() for each feature. One band with many geometries repaints once; many bands repaint many times and each needs its own cleanup.