Capture Map Click Coordinates in PyQGIS
Reading where the user clicked is the smallest useful interaction a plugin can offer, and it comes with a coordinate-system trap at every step. The mouse event carries widget pixels. toMapCoordinates() gives you the canvas CRS. The layer you want to compare against may be in a third CRS. Skipping any conversion produces numbers that look plausible and are wrong — which is much harder to notice than an exception.
This page is a focused recipe within Custom Map Tools and Canvas Interaction. It covers the ready-made emit-point tool, the conversion chain, reporting a position in a different CRS such as WGS84, and tracking coordinates live as the cursor moves.
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. - Some awareness of the project versus layer CRS distinction — see Coordinate Reference Systems in PyQGIS.
Use the ready-made tool
QgsMapToolEmitPoint exists precisely for this, and using it means writing no event handlers at all.
from qgis.gui import QgsMapToolEmitPoint
canvas = iface.mapCanvas()
tool = QgsMapToolEmitPoint(canvas)
def report(point, button):
print(f"{point.x():.2f}, {point.y():.2f}")
tool.canvasClicked.connect(report)
canvas.setMapTool(tool)
Breakdown: canvasClicked passes a QgsPointXY already converted to the canvas CRS, plus the mouse button that was pressed — filter on button == Qt.LeftButton if the right button should do something else. In the Python Console the module-level tool name keeps the object alive; inside a plugin it must be stored on self, or QGIS crashes as described in Create a Custom Map Tool in PyQGIS.
Convert to layer coordinates
The canvas CRS and the layer CRS are frequently different, and comparing across them is the single most common source of a click that never matches anything.
from qgis.core import QgsProject
from qgis.gui import QgsMapTool
from qgis.PyQt.QtCore import Qt
class LayerPickTool(QgsMapTool):
def __init__(self, canvas, layer):
super().__init__(canvas)
self.canvas = canvas
self.layer = layer
def canvasReleaseEvent(self, event):
if event.button() != Qt.LeftButton:
return
canvas_point = self.toMapCoordinates(event.pos())
layer_point = self.toLayerCoordinates(self.layer, event.pos())
print("canvas:", round(canvas_point.x(), 2), round(canvas_point.y(), 2))
print("layer :", round(layer_point.x(), 2), round(layer_point.y(), 2))
Breakdown: toLayerCoordinates(layer, pos) does the pixel conversion and the CRS transform into the layer's own system in one call — it is the method to reach for whenever the result will be compared against a geometry. toMapCoordinates() stops at the canvas CRS, which is right for display and wrong for comparison. When the project and layer share a CRS the two return identical values, which is exactly why the bug survives testing on a tidy project and appears on a real one.
Report a position in WGS84
Users generally want latitude and longitude regardless of what the project uses, so a display transform is worth building once.
from qgis.core import (
QgsCoordinateReferenceSystem,
QgsCoordinateTransform,
QgsProject,
)
project = QgsProject.instance()
wgs84 = QgsCoordinateReferenceSystem("EPSG:4326")
to_wgs84 = QgsCoordinateTransform(project.crs(), wgs84, project)
def report(point, button):
geo = to_wgs84.transform(point)
iface.statusBarIface().showMessage(
f"{geo.y():.6f}, {geo.x():.6f} ({project.crs().authid()}: "
f"{point.x():.1f}, {point.y():.1f})"
)
Breakdown: Constructing the transform once outside the handler avoids rebuilding it on every click, which matters if it is wired to mouse movement rather than clicks. Passing project as the third argument supplies the transform context so the datum path matches what the GUI would use. Note the output order: latitude then longitude for the geographic pair, which is the convention users expect, while QgsPointXY stores x (longitude) first — printing them in storage order is a classic way to send someone to the wrong hemisphere.
Track the cursor live
A live readout in the status bar is a small feature that makes a plugin feel finished. It runs on every mouse move, so the handler has to stay cheap.
class CoordinateReadoutTool(QgsMapTool):
def __init__(self, canvas, iface):
super().__init__(canvas)
self.canvas = canvas
self.iface = iface
project = QgsProject.instance()
self.to_wgs84 = QgsCoordinateTransform(
project.crs(), QgsCoordinateReferenceSystem("EPSG:4326"), project
)
def canvasMoveEvent(self, event):
point = self.toMapCoordinates(event.pos())
geo = self.to_wgs84.transform(point)
self.iface.statusBarIface().showMessage(
f"lat {geo.y():.5f} lon {geo.x():.5f}"
)
Breakdown: The transform lives on the instance, built once, because constructing a QgsCoordinateTransform involves resolving a datum path and is far too expensive to repeat sixty times a second. showMessage() overwrites the previous message rather than appending, which is what makes it suitable for a live readout. Anything heavier than this — a spatial query, a provider read — belongs in the click handler, not the move handler.
Format coordinates the way readers expect
A raw pair of floats is rarely what a user wants to see or copy. The convention differs by coordinate system, and getting it wrong makes a plugin feel unfinished even when the numbers are correct.
QGIS has the conversion built in, so there is no need to write degree arithmetic:
from qgis.core import QgsCoordinateFormatter, Qgis
def describe(point, crs):
if crs.isGeographic():
return QgsCoordinateFormatter.format(
point, Qgis.CoordinateOrder.YX, precision=1,
flags=QgsCoordinateFormatter.FlagDegreesUseStringSuffix,
format=Qgis.CoordinateDisplayType.MinutesSeconds,
)
return f"{point.x():,.0f} E {point.y():,.0f} N"
Breakdown: QgsCoordinateFormatter produces the degrees-minutes-seconds form with the hemisphere suffixes, matching what QGIS's own status bar shows. CoordinateOrder.YX puts latitude first, which is the convention for geographic display even though QgsPointXY stores x first. Branching on crs.isGeographic() means one function serves both cases without the caller deciding. The :,.0f format on the projected branch groups thousands and drops the decimals, because a click is never accurate to the millimetre and showing decimals implies otherwise.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.28 LTR | 3.9 | Identical API. statusBarIface() available throughout 3.x. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | QgsMapToolEmitPoint.canvasClicked signature unchanged; coordinate helpers unchanged. |
toMapCoordinates(), toLayerCoordinates() and QgsMapToolEmitPoint have been stable across the whole 3.x line.
Troubleshooting
- Coordinates are enormous or negative. Pixel positions were used directly.
event.pos()must go throughtoMapCoordinates()ortoLayerCoordinates(). - The click never matches a feature. The coordinates are in the canvas CRS and the layer is in another. Use
toLayerCoordinates(layer, event.pos()). - Latitude and longitude look swapped.
QgsPointXYstores x first, which is longitude. Printy()beforex()when reporting a geographic position. - The readout is jerky. The move handler is doing too much — most often rebuilding the coordinate transform on every event. Build it once in the constructor.
- Nothing is emitted. The tool object was collected. Keep a reference on the plugin instance.
- The right button triggers the action too. Filter on
event.button() == Qt.LeftButton, or on thebuttonargument ofcanvasClicked.
Conclusion
Capturing a click is a chain of coordinate spaces, and each conversion has a specific method: toMapCoordinates() for the canvas CRS, toLayerCoordinates() for comparison against geometry, and a QgsCoordinateTransform built once for display. Get the chain right and everything downstream — identify, snapping, measurement — falls into place.
Frequently Asked Questions
Which method converts a click to map coordinates?self.toMapCoordinates(event.pos()) on a QgsMapTool, which returns a QgsPointXY in the canvas CRS. QgsMapToolEmitPoint does the same conversion for you and hands the point to its canvasClicked signal.
Why does my click never match a feature?
The point is in the canvas CRS and the layer's geometry is in the layer's CRS. Use toLayerCoordinates(layer, event.pos()), which applies both the pixel conversion and the CRS transform.
How do I show latitude and longitude when the project uses a projected CRS?
Build a QgsCoordinateTransform from the project CRS to EPSG:4326 once, and transform each point before displaying it. Remember to print y() as latitude and x() as longitude.
Can I track the cursor without clicking?
Yes — override canvasMoveEvent. Keep it cheap: it fires on every mouse move, so build any coordinate transform in the constructor rather than in the handler.
How do I copy the clicked coordinate to the clipboard?
Format it first, then use Qt's clipboard: QApplication.clipboard().setText(text). Copying the raw floats is rarely what a user wants — a latitude-comma-longitude pair with six decimals pastes cleanly into almost every other tool, which is why it is worth formatting rather than dumping.
Does the click coordinate account for map rotation?
Yes. toMapCoordinates() uses the canvas's full transform, including any rotation set on the map. That is another reason to use it rather than computing the position from the pixel offset yourself, which would silently ignore rotation.
Can I capture a click without installing a map tool?
Not reliably. The canvas routes mouse events to its active tool, so intercepting them any other way means fighting the framework. Installing a lightweight QgsMapToolEmitPoint is the supported route and is only a few lines.
Does the canvas report coordinates while panning? Yes, continuously — which is another reason to keep move handlers cheap.
Can I restrict clicks to inside a particular layer's extent?
Yes — convert the click, build a QgsRectangle from the layer extent and test contains(). Rejecting clicks outside the data is friendlier than returning coordinates that cannot possibly match anything.