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.

Four coordinate spaces, three conversionsA click begins as widget pixels with the origin at the canvas top-left. toMapCoordinates converts it to the canvas CRS. toLayerCoordinates converts it further into the layer's own CRS, which is what a geometry comparison needs. A separate coordinate transform converts it to WGS84 for display to a user.Which space are your numbers in?event.pos()widget pixels(240, 90)y increases downwardcanvas CRStoMapCoordinates()504812, 5606340the project's CRSlayer CRStoLayerCoordinates()what a geometrycomparison needsdisplay CRSQgsCoordinateTransform12.4924, 50.5871for humanscomparing canvas coordinates to layer geometry: silently wrong

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer, running as a desktop application.
  • A plugin or a Python Console session with iface available.
  • 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.

Move handlers fire far more often than click handlersA timeline over one second of cursor movement shows roughly sixty move events and a single click event. The move handler is annotated as needing to stay trivial — a coordinate conversion and a status bar update — while the click handler can afford a spatial query, a database read or a dialog.One second of interaction — sixty moves, one clickcanvasMoveEventcanvasReleaseEventkeep triviala conversion and a status updatecan afford real worka spatial query or a dialog

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.

Four presentations of the same positionOne location is shown formatted four ways. Raw decimal degrees carry excessive precision. Degrees, minutes and seconds suit navigation and survey contexts. Projected metres with thousands separators and no decimals suit engineering. A latitude comma longitude pair with six decimals is the form users paste into other tools.Same point, four audiencesraw12.492373947, 41.890210043nanometre precision on a mouse clickimplies accuracy the data does not haveDMS41°53'24.8"N 12°29'32.5"Enavigation, survey, aviationlatitude first, hemisphere lettersmetres789 038 E 4 641 418 Nengineering and cadastral workno decimals, grouped thousandscopyable41.890210, 12.492374pastes into any other tool

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 versionPythonNotes
3.28 LTR3.9Identical API. statusBarIface() available throughout 3.x.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12QgsMapToolEmitPoint.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 through toMapCoordinates() or toLayerCoordinates().
  • 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. QgsPointXY stores x first, which is longitude. Print y() before x() 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 the button argument of canvasClicked.

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.