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.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer, running as a desktop application.
- A plugin skeleton with
initGui()andunload()— see Plugin Boilerplate & Structure. - A toolbar action to trigger the tool; Add a Toolbar Button to a QGIS Plugin covers creating one.
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 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.
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 version | Python | Notes |
|---|---|---|
| 3.28 LTR | 3.9 | Identical API. setAction() and deactivated available throughout 3.x. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | QgsMapToolHandler 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
pointPickedconnection was never disconnected. Disconnect inunload(). - The cursor never changes.
activate()was overridden without callingsuper().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.