Custom Map Tools and Canvas Interaction

A plugin that only adds a dialog is a form with a map behind it. The plugins people actually keep installed are the ones that let you point at something — click a parcel to see its history, drag a line to profile the terrain beneath it, rubber-band a box to select what falls inside. All of those are QgsMapTool subclasses, and the API is small enough to learn in an afternoon once the event flow is clear.

This guide sits inside QGIS Plugin Development. It covers the map tool lifecycle, converting screen pixels into map coordinates, drawing transient feedback with rubber bands, identifying the feature under the cursor, and connecting to the signals that tell your plugin the world changed underneath it. It assumes you already have a plugin skeleton — if not, start with Plugin Boilerplate & Structure.

How a custom map tool receives and handles eventsA user clicks a toolbar action, which calls setMapTool on the canvas and activates the tool. Mouse press, move and release events are delivered to the tool's canvasPressEvent, canvasMoveEvent and canvasReleaseEvent handlers. Inside those handlers the tool converts pixel positions to map coordinates and updates a rubber band. When another tool is chosen, deactivate fires and the rubber band must be removed.Activate, receive events, draw feedback, clean uptoolbar actionsetMapTool(tool)activate()set the cursoryour QgsMapToolcanvasPressEventcanvasMoveEventcanvasReleaseEventevent.pos() is in pixelstoMapCoordinates()pixels → map unitsQgsRubberBandtransient feedbackdeactivate()reset the banda tool that does not clean up in deactivate() leaves its band on the canvas forever

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer, running as a desktop application — map tools need a canvas.
  • A working plugin skeleton with an action in the toolbar; see Add a Toolbar Button to a QGIS Plugin.
  • Familiarity with Qt signals and slots, since everything here is event-driven — Qt Designer for GIS Interfaces covers the basics.
  • A vector layer loaded, for the identify examples.

The QgsMapTool lifecycle

A map tool is a plain Python class deriving from QgsMapTool (or QgsMapToolEmitPoint for the common click-a-point case). The canvas holds exactly one active tool at a time and routes mouse and key events to it.

from qgis.gui import QgsMapTool
from qgis.PyQt.QtCore import Qt, pyqtSignal
from qgis.PyQt.QtGui import QCursor


class PointPickTool(QgsMapTool):
    """Emits the map coordinate of every left click."""

    pointPicked = pyqtSignal(object)

    def __init__(self, canvas):
        super().__init__(canvas)
        self.canvas = canvas

    def activate(self):
        super().activate()
        self.canvas.setCursor(QCursor(Qt.CrossCursor))

    def canvasReleaseEvent(self, event):
        if event.button() != Qt.LeftButton:
            return
        point = self.toMapCoordinates(event.pos())
        self.pointPicked.emit(point)

    def deactivate(self):
        super().deactivate()
        self.deactivated.emit()

Breakdown: pyqtSignal(object) lets the tool report results without knowing who is listening, which keeps it reusable — the plugin connects to pointPicked and decides what to do. activate() and deactivate() are called by the canvas when the tool is set or replaced; always call super() so the base class housekeeping still runs. Handling the release rather than the press is the convention: it lets a user press, think better of it, drag away and release harmlessly. Filtering on Qt.LeftButton leaves the right button free for the context menu.

Installing it is two lines, and keeping a reference is mandatory:

self.tool = PointPickTool(self.iface.mapCanvas())
self.tool.pointPicked.connect(self.on_point_picked)
self.iface.mapCanvas().setMapTool(self.tool)

Breakdown: Storing the tool on self prevents Python from garbage-collecting it while the canvas still holds a pointer, which is a classic cause of a hard crash rather than a clean error. To hand control back, call self.iface.mapCanvas().unsetMapTool(self.tool) — usually in the plugin's unload(). Create a Custom Map Tool in PyQGIS walks through wiring it to a checkable toolbar button so the button state follows the active tool.

From screen pixels to map coordinates

Mouse events carry positions in widget pixels with the origin at the canvas's top-left corner and y increasing downward. Map coordinates have their origin wherever the CRS puts it and y increasing upward. The conversion is one call, and getting it wrong produces coordinates that look plausible but are mirrored or wildly out of range.

Pixel space versus map spaceOn the left, a canvas rectangle with its origin at the top-left corner, x increasing to the right and y increasing downward, and a click marked at 240 by 90 pixels. On the right, the same view in map coordinates with the origin at the bottom-left, y increasing upward, and the same click reported as an easting and northing. An arrow between them is labelled toMapCoordinates.The y axis flips — never assume the numbers are interchangeableevent.pos() — pixels(0, 0) top-leftx →y ↓(240, 90)map coordinatesorigin at bottom-lefteasting →northing ↑(504 812, 5 606 340)toMapCoordinates()result is in the canvas CRS

def canvasMoveEvent(self, event):
    point = self.toMapCoordinates(event.pos())
    crs = self.canvas.mapSettings().destinationCrs().authid()
    self.iface.mainWindow().statusBar().showMessage(
        f"{point.x():.2f}, {point.y():.2f}  [{crs}]"
    )

Breakdown: toMapCoordinates() is a method on QgsMapTool and handles the flip, the pan offset and the current zoom for you. The result is in the canvas CRS, not the layer's — comparing it directly against a layer geometry in a different CRS is the classic source of a click that never matches anything. When you need layer coordinates, use the two-argument form self.toLayerCoordinates(layer, event.pos()), which applies the transform as well. Capture Map Click Coordinates in PyQGIS covers the CRS handling in detail.

Drawing feedback with a rubber band

A QgsRubberBand is a lightweight overlay drawn on top of the canvas. It is not a layer, nothing is stored, and it repaints instantly — exactly what interactive feedback needs.

from qgis.core import QgsWkbTypes
from qgis.gui import QgsRubberBand
from qgis.PyQt.QtGui import QColor


class BoxSelectTool(QgsMapTool):
    def __init__(self, canvas):
        super().__init__(canvas)
        self.canvas = canvas
        self.origin = None
        self.band = QgsRubberBand(canvas, QgsWkbTypes.PolygonGeometry)
        self.band.setColor(QColor(15, 118, 110, 60))
        self.band.setStrokeColor(QColor(15, 118, 110))
        self.band.setWidth(2)

    def canvasPressEvent(self, event):
        self.origin = self.toMapCoordinates(event.pos())
        self.band.reset(QgsWkbTypes.PolygonGeometry)

    def canvasMoveEvent(self, event):
        if self.origin is None:
            return
        current = self.toMapCoordinates(event.pos())
        self.band.reset(QgsWkbTypes.PolygonGeometry)
        for corner in (
            self.origin,
            (self.origin.x(), current.y()),
            current,
            (current.x(), self.origin.y()),
        ):
            self.band.addPoint(QgsPointXY(*corner) if isinstance(corner, tuple) else corner, False)
        self.band.closePoints()

    def deactivate(self):
        self.band.reset(QgsWkbTypes.PolygonGeometry)
        super().deactivate()

Breakdown: The geometry type passed to the constructor decides what the band draws — PointGeometry, LineGeometry or PolygonGeometry. The fill colour's fourth argument is alpha, so 60 gives a translucent wash that does not hide the map beneath. addPoint(..., False) suppresses the repaint for every intermediate vertex; closePoints() triggers it once at the end, which keeps dragging smooth. Resetting in deactivate() is what stops an abandoned rectangle from sitting on the canvas for the rest of the session. Highlight a Feature with a Rubber Band in PyQGIS covers the geometry-following variant used for highlighting.

Identifying the feature under the cursor

Clicks are imprecise, so a hit test against an exact point almost never matches. The convention is to build a small search rectangle around the click, sized in map units derived from the current zoom, and query with that.

from qgis.core import QgsFeatureRequest, QgsRectangle


def features_at(self, layer, pos, pixel_radius=6):
    """Features whose geometry falls within pixel_radius of a canvas position."""
    point = self.toLayerCoordinates(layer, pos)
    radius = self.canvas.mapUnitsPerPixel() * pixel_radius
    box = QgsRectangle(
        point.x() - radius, point.y() - radius,
        point.x() + radius, point.y() + radius,
    )
    request = QgsFeatureRequest().setFilterRect(box)
    return list(layer.getFeatures(request))

Breakdown: toLayerCoordinates() does the pixel conversion and the CRS transform into the layer's own system, which is what makes the rectangle comparable to the stored geometry. mapUnitsPerPixel() converts a comfortable pixel tolerance into map units at the current zoom, so the tool behaves identically whether the user is zoomed to a street or a country. setFilterRect() pushes the bounding-box test down to the provider, and on an indexed source it is close to free — the same principle as the spatial index in Geometry Operations and Spatial Predicates. Note the result is a candidate list from a box test; if you need exact hits, follow up with geometry.intersects(). Identify the Feature Under the Cursor in PyQGIS builds this into a complete identify tool.

Snapping to existing geometry

A digitising tool that lets the user click "close enough" to a vertex and quietly records a point half a metre away produces slivers and gaps. QGIS ships a snapping utility that map tools can borrow, so your tool obeys the same snapping settings the user configured for the built-in tools.

Raw click versus a snapped clickOn the left, a cursor clicks near but not on the corner of a polygon, and the recorded point sits a short distance away, creating a visible gap. On the right, the same click is shown with a dashed tolerance circle around it; the snapping utility resolves it onto the existing vertex so the new point coincides exactly with the polygon corner.Same click, with and without the snapping utilitytoMapCoordinates() alonerecorded herea sliver is bornthe point is wherever the pixel wassnapUtils.snapToMap()snapped to theexisting vertextolerance comes from project settings

def canvasReleaseEvent(self, event):
    match = self.canvas.snappingUtils().snapToMap(event.pos())
    point = match.point() if match.isValid() else self.toMapCoordinates(event.pos())
    self.pointPicked.emit(point)

Breakdown: snappingUtils() is owned by the canvas and already configured from the project's snapping options, so your tool inherits the user's tolerance, mode and target layers for free. snapToMap() takes the raw pixel position and returns a QgsPointLocator.Match. isValid() is false when nothing was within tolerance, which is why the fall-back to the unsnapped coordinate matters — without it, clicking in empty space would return a null point. The match also carries match.layer() and match.featureId(), so a tool can report what it snapped to, not just where.

Keyboard modifiers and cancelling

Users expect Escape to abort and modifier keys to change behaviour. Both arrive through methods you override, and handling them is what separates a tool that feels native from one that feels like a prototype.

from qgis.PyQt.QtCore import Qt


def keyPressEvent(self, event):
    if event.key() == Qt.Key_Escape:
        self.origin = None
        self.band.reset(QgsWkbTypes.PolygonGeometry)
        event.ignore()


def canvasReleaseEvent(self, event):
    additive = bool(event.modifiers() & Qt.ShiftModifier)
    self.selectionRequested.emit(self.current_rect(), additive)

Breakdown: Calling event.ignore() after handling Escape lets the event continue propagating, so QGIS's own "stop rendering" behaviour still works — swallowing it would break a shortcut the user relies on elsewhere. event.modifiers() is a bit flag tested with &, and Shift-to-add is the selection convention across the whole application, so following it costs one line and saves users from having to learn your tool's own rules.

Reacting to signals

A map tool is only half of the interaction story. The other half is knowing when the project changed — a layer was removed, the selection changed, the user finished an edit — so your plugin does not act on a stale reference.

from qgis.core import QgsProject

project = QgsProject.instance()
project.layersAdded.connect(self.on_layers_added)
project.layerWillBeRemoved.connect(self.on_layer_removed)

layer.selectionChanged.connect(self.on_selection_changed)
layer.editingStopped.connect(self.on_edits_committed)

Breakdown: layerWillBeRemoved fires before the layer is destroyed, which is the only safe moment to drop your reference to it — acting on layersRemoved and then touching the layer crashes. selectionChanged carries the added and removed feature IDs, so a handler does not need to re-read the whole selection. editingStopped fires on both commit and rollback, so check layer.isEditable() if you need to distinguish them.

Every one of these connections must be undone in the plugin's unload(), or a reloaded plugin will have two handlers attached and every action will run twice:

def unload(self):
    QgsProject.instance().layersAdded.disconnect(self.on_layers_added)
    self.iface.mapCanvas().unsetMapTool(self.tool)

Breakdown: Disconnecting mirrors the connection exactly. unsetMapTool() restores whatever tool was active before, leaving the canvas usable rather than stuck with a dangling tool object. This is the same discipline described in Plugin Boilerplate & Structure, and the reason Reload a QGIS Plugin Without Restarting is worth setting up early — duplicate-handler bugs only appear after a reload. Connect to Layer and Project Signals in PyQGIS lists the signals worth knowing.

A complete measuring tool

Putting the pieces together, here is a tool small enough to read in one sitting that nonetheless behaves properly: it snaps, draws live feedback, reports a running distance, respects Escape, and cleans up after itself.

from qgis.core import QgsDistanceArea, QgsProject, QgsWkbTypes
from qgis.gui import QgsMapTool, QgsRubberBand
from qgis.PyQt.QtCore import Qt, pyqtSignal
from qgis.PyQt.QtGui import QColor, QCursor


class MeasureTool(QgsMapTool):
    """Click to add vertices; reports the running ellipsoidal length."""

    lengthChanged = pyqtSignal(float)

    def __init__(self, canvas):
        super().__init__(canvas)
        self.canvas = canvas
        self.points = []
        self.band = QgsRubberBand(canvas, QgsWkbTypes.LineGeometry)
        self.band.setColor(QColor(180, 83, 9))
        self.band.setWidth(3)

        self.calculator = QgsDistanceArea()
        self.calculator.setSourceCrs(
            canvas.mapSettings().destinationCrs(),
            QgsProject.instance().transformContext(),
        )
        self.calculator.setEllipsoid(QgsProject.instance().ellipsoid())

    def activate(self):
        super().activate()
        self.canvas.setCursor(QCursor(Qt.CrossCursor))

    def canvasReleaseEvent(self, event):
        if event.button() == Qt.RightButton:
            self._reset()
            return
        match = self.canvas.snappingUtils().snapToMap(event.pos())
        point = match.point() if match.isValid() else self.toMapCoordinates(event.pos())
        self.points.append(point)
        self.band.addPoint(point, True)
        self.lengthChanged.emit(self._length())

    def keyPressEvent(self, event):
        if event.key() == Qt.Key_Escape:
            self._reset()
        event.ignore()

    def _length(self):
        total = 0.0
        for start, end in zip(self.points, self.points[1:]):
            total += self.calculator.measureLine(start, end)
        return total

    def _reset(self):
        self.points.clear()
        self.band.reset(QgsWkbTypes.LineGeometry)
        self.lengthChanged.emit(0.0)

    def deactivate(self):
        self._reset()
        super().deactivate()

Breakdown: The QgsDistanceArea is built once in the constructor rather than per click, and configured with both the canvas CRS and the project ellipsoid so measureLine() returns real metres rather than map units — the same distinction covered in Geometry Operations and Spatial Predicates. addPoint(point, True) repaints immediately, which is right here because clicks are infrequent; the False form used earlier is for the high-frequency move handler. Right-click resets rather than opening a menu, matching the built-in measure tool. _reset() is called from three places — right click, Escape and deactivation — which is exactly why it is a method rather than three copies of the same three lines. The tool emits lengthChanged instead of touching a label directly, so a dock widget or a status bar can consume it without the tool knowing which.

Connecting it from the plugin is unchanged from the simple case, and the signal makes the display trivial:

self.measure = MeasureTool(self.iface.mapCanvas())
self.measure.lengthChanged.connect(
    lambda metres: self.iface.mainWindow().statusBar().showMessage(f"{metres:,.1f} m")
)
self.iface.mapCanvas().setMapTool(self.measure)

Breakdown: The lambda keeps formatting out of the tool, which is the right split — a tool that returns a number is reusable, one that writes a formatted string into a specific widget is not.

Troubleshooting interaction bugs

Interaction bugs are harder to diagnose than logic bugs because there is no stack trace — the tool simply does nothing, or does something once and then stops. These are the recurring shapes.

  • Nothing happens when you click. The tool was never installed, or another tool replaced it. Print canvas.mapTool() and confirm it is your instance; a checkable toolbar action that was not connected to setMapTool() looks active while doing nothing.
  • The first click works, then nothing. Something inside the handler raised. Exceptions in a Qt event handler are printed to the QGIS log rather than surfacing, and the tool keeps running in a half-initialised state. Wrap the handler body while developing and check the Log Messages panel — see Debugging PyQGIS Scripts.
  • Coordinates are enormous or negative. A pixel position was used where a map coordinate was expected. event.pos() is never a map coordinate; it must go through toMapCoordinates() or toLayerCoordinates() first.
  • The rubber band flickers or lags on drag. Every addPoint() is repainting. Pass False for the update argument on intermediate vertices and let the final call repaint once.
  • The tool works in one project and not another. Almost always a CRS assumption. A tool that compares canvas coordinates against layer geometry works perfectly while both happen to share a CRS and fails silently the moment they do not.
  • Move events never arrive. QgsMapTool only receives move events while a button is held unless you opt in. Set self.setCursor(...) and ensure the canvas has mouse tracking enabled, or track state from press and release instead.

A quick way to confirm events are reaching the tool at all is to log the raw position at the top of each handler before any conversion. If nothing is logged the problem is installation; if positions appear but the results are wrong, the problem is conversion.

Key takeaways

  • Keep a reference to your tool. The canvas holds a non-owning pointer; letting Python collect the tool crashes QGIS rather than raising.
  • Handle release, not press. It gives the user a chance to abort a click by dragging away, which is the behaviour every built-in tool has.
  • toMapCoordinates() returns canvas-CRS coordinates. Use toLayerCoordinates(layer, pos) whenever you are about to compare against layer geometry.
  • Size hit tolerances in pixels, converted through mapUnitsPerPixel(). A fixed map-unit tolerance behaves completely differently at different zooms.
  • Rubber bands are overlays, not layers. They cost nothing, but they must be reset in deactivate() or they linger.
  • Disconnect every signal in unload(). Duplicate handlers after a reload are the most confusing plugin bug there is.

Frequently Asked Questions

Why does QGIS crash when I activate my map tool? Almost always because the tool object was garbage-collected. The canvas keeps a raw pointer to it, so the tool must be stored on the plugin instance — self.tool = MyTool(canvas) — and not created as a local variable inside the function that installs it.

My clicks never match any feature. What is wrong? Either the coordinates are in the wrong CRS or the tolerance is zero. Use toLayerCoordinates(layer, event.pos()) rather than toMapCoordinates() when comparing against a layer, and build a search rectangle sized from mapUnitsPerPixel() instead of testing an exact point.

Should I subclass QgsMapTool or QgsMapToolEmitPoint?QgsMapToolEmitPoint if all you need is a single clicked coordinate — it gives you a ready-made canvasClicked signal. Subclass QgsMapTool directly when you need drag behaviour, custom feedback, or key handling.

How do I stop my rubber band from staying on the canvas? Call band.reset(geometry_type) in deactivate(), and again in unload() for safety. A rubber band is owned by the canvas scene, so nothing removes it automatically when your tool goes out of scope.

Why does my plugin react twice to every click after I reload it? The previous instance's signal connections were never removed. Disconnect every signal you connect in the matching unload() method; a reload creates a second set of handlers on top of the first otherwise.