Create a Custom Map Tool in PyQGIS

The first custom map tool most people write crashes QGIS outright, and the reason is not in the tool at all. QgsMapCanvas.setMapTool() stores a non-owning pointer, so if the only Python reference to the tool is a local variable inside the function that installed it, the object is collected as soon as that function returns — and the next mouse move dereferences freed memory. The fix is one character: self.tool rather than tool.

This page is a focused recipe within Custom Map Tools and Canvas Interaction. It covers a minimal working tool, wiring it to a checkable toolbar button so the interface state stays honest, and the unload discipline that lets the plugin be reloaded during development.

Who owns the map toolThe plugin instance holds a strong reference to the tool, keeping it alive. The canvas holds only a weak, non-owning pointer used to deliver events. If the plugin's reference is a local variable that goes out of scope, the tool is garbage collected while the canvas still points at it, and the next event dereferences freed memory.The canvas points at the tool but does not keep it aliveplugin instanceself.tool = MyTool()strong referenceMyToolQgsMapTool subclassQgsMapCanvasdelivers eventsnon-owning pointertool = MyTool() — a local variablecollected on return; the next mouse move crashes QGIS

Prerequisites

A minimal working tool

Subclass QgsMapTool, override the event handlers you need, and emit a signal rather than acting directly.

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


class PointPickTool(QgsMapTool):
    """Emit the map coordinate of each left click."""

    pointPicked = pyqtSignal(QgsPointXY)

    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
        self.pointPicked.emit(self.toMapCoordinates(event.pos()))

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

Breakdown: pyqtSignal(QgsPointXY) declares the tool's output without binding it to any particular consumer, which is what makes the tool reusable across dialogs and dock widgets. activate() and deactivate() are called by the canvas when the tool is installed or replaced — always call super() so the base class housekeeping runs. Handling release rather than press follows every built-in tool: it lets a user press, change their mind, drag away and release harmlessly. self.deactivated.emit() in deactivate() is what lets a checkable toolbar button untick itself when the user picks a different tool.

Wire it to a checkable action

A tool button that stays pressed after the user has switched to pan is worse than no button. Making the action checkable and listening for deactivated keeps the two in step.

from qgis.PyQt.QtWidgets import QAction
from qgis.PyQt.QtGui import QIcon


class MyPlugin:
    def __init__(self, iface):
        self.iface = iface
        self.tool = None
        self.action = None

    def initGui(self):
        self.action = QAction(QIcon(":/plugins/myplugin/pick.svg"), "Pick a point",
                              self.iface.mainWindow())
        self.action.setCheckable(True)
        self.action.triggered.connect(self.toggle_tool)
        self.iface.addToolBarIcon(self.action)

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

    def toggle_tool(self, checked):
        canvas = self.iface.mapCanvas()
        if checked:
            canvas.setMapTool(self.tool)
        else:
            canvas.unsetMapTool(self.tool)

    def on_point_picked(self, point):
        self.iface.messageBar().pushInfo(
            "Picked", f"{point.x():.2f}, {point.y():.2f}"
        )

Breakdown: self.tool on the plugin instance is the strong reference that keeps the object alive — this is the line that prevents the crash. setAction() on the tool tells QGIS which action represents it, and QGIS then unchecks that action automatically when another tool takes over, so the button never lies about what is active. unsetMapTool() restores whatever tool was active before rather than leaving the canvas with nothing. messageBar().pushInfo() is the idiomatic way to report a result without a modal dialog interrupting the user.

Handle activation state cleanly

The states a map tool moves throughA tool starts idle with its action unchecked. Clicking the action calls setMapTool and moves it to active, where activate runs and the cursor changes. Choosing a different tool moves it back to idle via deactivate, which also unchecks the action. Plugin unload calls unsetMapTool and disconnects the signals, ending in a destroyed state.Every arrow out of "active" must leave the canvas usableidleaction uncheckedtool alive, not installedactiveactivate() ranreceiving eventsreplacedanother tool chosendeactivate() runsunloadedunsetMapTool()signals disconnectedsetMapToolaction unchecks itself via setAction()

The unload path is where a development loop lives or dies. A plugin that leaves its tool installed and its signals connected will behave strangely the moment it is reloaded.

    def unload(self):
        canvas = self.iface.mapCanvas()
        if canvas.mapTool() is self.tool:
            canvas.unsetMapTool(self.tool)
        try:
            self.tool.pointPicked.disconnect(self.on_point_picked)
        except TypeError:
            pass                       # already disconnected
        self.iface.removeToolBarIcon(self.action)
        self.action.deleteLater()
        self.tool = None
        self.action = None

Breakdown: Checking canvas.mapTool() is self.tool avoids unsetting a tool the user has since switched away from, which would rip the pan tool out from under them. disconnect() raises TypeError when the connection is already gone, so the guard makes unload() safe to call twice. deleteLater() schedules the Qt object for destruction on the event loop rather than freeing it while a signal may still be in flight. Setting the attributes to None releases the last Python references. This discipline is what makes Reload a QGIS Plugin Without Restarting actually work.

Choose the right base class

QgsMapTool is the general case, and QGIS ships three more specialised bases that remove boilerplate when they fit. Starting from the right one can halve the code.

Four base classes and what each gives youQgsMapTool is the general base with nothing implemented, suited to custom drag and key handling. QgsMapToolEmitPoint adds a canvasClicked signal carrying the map point. QgsMapToolExtent adds rubber-band rectangle dragging and an extentChanged signal. QgsMapToolIdentify adds feature identification against visible layers. Each row notes when to choose it.Start from the closest base, not always the most generalbase classgives youchoose it whenQgsMapToolnothing — you override alldrag, keys, custom feedbackQgsMapToolEmitPointcanvasClicked(point, button)you only need one coordinateQgsMapToolExtentrubber-band drag + extentChangedthe user drags a boxQgsMapToolIdentifyidentify across visible layersclicking reports features

For the single-coordinate case, the specialised base removes the class entirely:

from qgis.gui import QgsMapToolEmitPoint
from qgis.PyQt.QtCore import Qt

self.tool = QgsMapToolEmitPoint(self.iface.mapCanvas())
self.tool.setAction(self.action)
self.tool.canvasClicked.connect(self.on_clicked)


def on_clicked(self, point, button):
    if button != Qt.LeftButton:
        return
    self.iface.messageBar().pushInfo("Picked", f"{point.x():.2f}, {point.y():.2f}")

Breakdown: QgsMapToolEmitPoint already converts the click to map coordinates and emits it, so there is no subclass, no canvasReleaseEvent, and no conversion code to get wrong. The button argument comes free, which is what makes the right-click filter a single line. Everything else — the strong reference on self, setAction() for the checkable button, disconnecting in unload() — applies exactly as it does to a hand-written subclass.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.28 LTR3.9Identical API. setAction() and deactivated available throughout 3.x.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12QgsMapToolHandler offers a higher-level way to bind a tool to an action; the manual form still works.

QgsMapTool, its event handlers and the canvas install methods are unchanged across the 3.x line.

Troubleshooting

  • QGIS crashes when the tool activates. The tool was a local variable and has been garbage collected. Store it on the plugin instance.
  • The toolbar button stays pressed after switching tools. setAction() was not called on the tool, so QGIS cannot untick it.
  • Nothing happens on click. The tool was created but never installed, or another tool replaced it. Print canvas.mapTool() to see what is actually active.
  • The first click works and then nothing does. An exception was raised inside the handler. Qt swallows it to the log rather than surfacing it — check the Log Messages panel.
  • After a reload, every click fires twice. The previous instance's pointPicked connection was never disconnected. Disconnect in unload().
  • The cursor never changes. activate() was overridden without calling super().activate().

Conclusion

A custom map tool is a small class plus two pieces of bookkeeping: keep a strong reference on the plugin instance so the object outlives the call that installed it, and bind the tool to its action with setAction() so the interface reflects reality. Getting the unload path right costs six lines and makes the whole development loop pleasant.

Frequently Asked Questions

Why does QGIS crash when I activate my map tool? The canvas keeps a non-owning pointer to the tool. If your only reference was a local variable, Python collects the object as soon as the function returns and the next event dereferences freed memory. Store it as self.tool.

How do I make my toolbar button untick when the user picks another tool? Call tool.setAction(action) on a checkable action. QGIS then unchecks that action automatically whenever a different tool becomes active.

Should I handle canvasPressEvent or canvasReleaseEvent? Release, for a click. It matches every built-in tool and lets a user abort by dragging away before letting go. Use press when you need to start a drag.

What must unload() do for a map tool? Unset the tool if it is still active, disconnect every signal you connected, remove the toolbar icon, and drop the references. Skipping the disconnect is what causes duplicate handlers after a plugin reload.

How do I make my tool the active one when the plugin loads? Do not. Taking over the canvas without the user asking is disorienting, and it overrides whatever they were doing. Install the tool only when its action is triggered, and restore the previous tool with unsetMapTool() when the action is unchecked.

Can two plugins have map tools active at the same time? No. The canvas holds exactly one active tool, and installing a second replaces the first. That is why setAction() matters: it lets QGIS untick your toolbar button automatically when another plugin's tool takes over, so your interface never claims to be active when it is not.

Should the tool do the work, or emit a signal? Emit a signal. A tool that reports what happened stays reusable across dialogs and panels, while one that acts directly is welded to whatever it acts on and cannot be reused.

Can a map tool outlive the plugin that created it? It should not. Unset it and drop the reference in unload(), or the canvas keeps pointing at an object your plugin no longer manages.