Identify the Feature Under the Cursor in PyQGIS

Nobody clicks precisely. A user aiming at a 1 px road line lands two or three pixels away, and an identify tool that tests the exact clicked point matches nothing perhaps nine times in ten. Every usable identify tool therefore builds a small search rectangle around the click — and sizes it in pixels, converted to map units at the current zoom, so it behaves the same whether the user is looking at a street or a continent.

This page is a focused recipe within Custom Map Tools and Canvas Interaction. It covers the search-rectangle query, ranking overlapping results, searching several layers, and presenting the answer without blocking the user.

A tolerance in pixels behaves the same at every zoomOn the left, an exact click point falls beside a thin road line and matches nothing. In the middle, a six pixel search box around the same click catches the line. On the right, the same six pixel box at a zoomed-out scale covers a much larger ground distance, which is what keeps the tool feeling identical to the user at any zoom.Size the tolerance in pixels, convert to map units at query timeexact point3 px away from the lineno match6 px box · zoomed inbox ≈ 12 m on the groundmatch6 px box · zoomed outbox ≈ 900 m on the groundmatch, same feel

Prerequisites

  • QGIS 3.34 LTR (bundled Python 3.12) or newer, running as a desktop application.
  • A plugin skeleton — see Create a Custom Map Tool in PyQGIS for the tool wiring this page builds on.
  • A vector layer with attributes worth reporting.

Query with a search rectangle

The rectangle is built in layer coordinates, sized from a pixel tolerance converted through the current zoom.

from qgis.core import QgsFeatureRequest, QgsRectangle
from qgis.gui import QgsMapTool
from qgis.PyQt.QtCore import Qt, pyqtSignal


class IdentifyTool(QgsMapTool):
    featuresFound = pyqtSignal(object, list)

    PIXEL_TOLERANCE = 6

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

    def canvasReleaseEvent(self, event):
        if event.button() != Qt.LeftButton:
            return
        point = self.toLayerCoordinates(self.layer, event.pos())
        radius = self.canvas.mapUnitsPerPixel() * self.PIXEL_TOLERANCE
        box = QgsRectangle(
            point.x() - radius, point.y() - radius,
            point.x() + radius, point.y() + radius,
        )
        request = QgsFeatureRequest().setFilterRect(box)
        self.featuresFound.emit(point, list(self.layer.getFeatures(request)))

Breakdown: toLayerCoordinates() converts the pixel position and transforms into the layer's CRS in one call — using toMapCoordinates() here is the mistake that makes an identify tool match nothing on any project whose layer CRS differs from the project's. mapUnitsPerPixel() returns the current ground distance of one screen pixel, so multiplying by a pixel tolerance produces a map-unit radius that automatically adapts to the zoom. setFilterRect() pushes the box test down to the provider, which on an indexed source is close to free.

Note this returns candidates from a bounding-box test. For a polygon layer that is usually good enough; for precision, follow with an exact test.

Rank overlapping results

A click on a city centre can land inside a parcel, a district and a region at once. Returning all of them and letting the caller choose is more useful than picking arbitrarily — but the order should be meaningful.

from qgis.core import QgsGeometry, QgsPointXY


def rank(point, features):
    """Smallest-area first, so the most specific feature comes first."""
    click = QgsGeometry.fromPointXY(QgsPointXY(point))
    exact = [f for f in features if f.geometry().intersects(click)]
    pool = exact or features
    return sorted(pool, key=lambda f: f.geometry().area() or float("inf"))

Breakdown: Testing intersects() against the exact click point separates genuine hits from box-only candidates. Falling back to the full candidate list when nothing contains the point is deliberate — for a line or point layer nothing ever contains a click, and returning an empty list there would break the tool. Sorting by ascending area puts the smallest, most specific feature first, which matches what a user pointing at something means. or float("inf") sends zero-area geometries — lines and points — to the end rather than the front.

Search several layers

Identifying across the whole project is what users expect from the built-in tool, and it is a loop with two guards.

One click, four layers, two answersA single click fans out to four layers. The parcels layer returns one hit and the districts layer returns one hit. The hillshade raster is skipped because it has no features to identify. The utilities layer is skipped because scale-based visibility hides it at the current zoom, matching what the user can actually see.Only identify what the user can actually seeone click6 px toleranceparcels1 hitdistricts1 hithillshaderaster — skippedutilitieshidden at this scale2 resultssmallest area first

from qgis.core import QgsProject, QgsVectorLayer


def identify_all(tool, canvas, pos, pixel_tolerance=6):
    results = {}
    scale = canvas.scale()
    for layer in canvas.layers():
        if not isinstance(layer, QgsVectorLayer):
            continue
        if layer.hasScaleBasedVisibility() and not layer.isInScaleRange(scale):
            continue
        point = tool.toLayerCoordinates(layer, pos)
        radius = canvas.mapUnitsPerPixel() * pixel_tolerance
        box = QgsRectangle(
            point.x() - radius, point.y() - radius,
            point.x() + radius, point.y() + radius,
        )
        found = list(layer.getFeatures(QgsFeatureRequest().setFilterRect(box)))
        if found:
            results[layer] = rank(point, found)
    return results

Breakdown: canvas.layers() returns only the visible layers in draw order, so an unticked layer is excluded automatically — which is what the user expects. The isinstance check skips rasters, which have no features to identify this way. isInScaleRange() respects a layer's scale-dependent visibility, so a detail layer hidden at the current zoom is not silently identified. Converting the click separately per layer is essential: each layer may be in a different CRS, so one shared point would be wrong for all but one of them.

Show the result without blocking

A modal dialog per click makes an identify tool tiring. The message bar and a dock widget are both better.

def on_features_found(self, point, features):
    if not features:
        self.iface.messageBar().pushInfo("Identify", "Nothing found here")
        return
    feature = features[0]
    summary = ", ".join(
        f"{field.name()}={feature[field.name()]}"
        for field in feature.fields()[:3]
    )
    self.iface.messageBar().pushInfo(
        f"{len(features)} feature(s)", summary
    )

Breakdown: pushInfo() shows a dismissible bar at the top of the map that disappears on its own, so rapid clicking stays fluid. Reporting the count alongside the first feature's summary tells the user there is more without forcing them to page through it. Slicing to the first three fields keeps the line readable — a full attribute dump belongs in a dock widget, which is where a serious identify tool puts it. See Add a Custom Dock Widget with PyQGIS.

Pairing the result with a highlight makes it obvious what was matched — Highlight a Feature with a Rubber Band covers that half.

Use the built-in identify tool

Before writing any of the above, it is worth knowing that QGIS already ships the whole thing. QgsMapToolIdentify implements the tolerance, the multi-layer search and the result structure, and subclassing it is often five lines.

Hand-written versus QgsMapToolIdentifyA hand-written tool requires you to implement the coordinate conversion, the pixel tolerance, the layer loop, the scale-visibility check and the result structure. Subclassing QgsMapToolIdentify provides all five, leaving only the presentation of the results to you.Five things you can stop writingresponsibilityby handQgsMapToolIdentifypixel → layer coordinatestolerance in pixelsloop visible layersscale-visibility checkpresent the resultsyouyouyouyouyouyou

from qgis.gui import QgsMapToolIdentify


class QuickIdentify(QgsMapToolIdentify):
    def canvasReleaseEvent(self, event):
        results = self.identify(
            event.x(), event.y(),
            self.TopDownStopAtFirst,
            self.VectorLayer,
        )
        for result in results:
            print(result.mLayer.name(), dict(result.mAttributes))

Breakdown: identify() takes pixel coordinates directly and handles every conversion internally. TopDownStopAtFirst returns only the topmost hit, which is what a simple identify wants; TopDownAll returns every match in draw order, and LayerSelection restricts to the active layer. VectorLayer filters out rasters. Each result carries mLayer, mFeature and mAttributes, so the presentation code is all that remains yours. Write the manual version only when you need something the base class does not offer — a custom tolerance policy, a non-standard layer set, or ranking by area as shown above.

QGIS version compatibility

The examples target QGIS 3.34 LTR (Python 3.12).

QGIS versionPythonNotes
3.28 LTR3.9Identical API. QgsMapToolIdentifyFeature also available as a ready-made alternative.
3.34 LTR3.12Baseline for this page.
3.40 / 3.443.12QgsMapToolIdentify gained additional result formats; the manual approach here is unchanged.

mapUnitsPerPixel(), toLayerCoordinates() and setFilterRect() are unchanged across the 3.x line.

Troubleshooting

  • Clicks never match anything. Either the tolerance is zero — an exact point test — or toMapCoordinates() was used where toLayerCoordinates() was needed.
  • The tolerance feels wrong when zoomed. It is fixed in map units. Multiply a pixel tolerance by mapUnitsPerPixel() at query time instead.
  • Too many features are returned. The bounding-box test is coarse. Follow it with an exact intersects() against the click point.
  • A hidden layer is being identified. QgsProject.mapLayers() was used instead of canvas.layers(). Only the latter reflects what is visible.
  • The wrong feature is reported first. No ranking was applied. Sort by ascending area so the most specific feature leads.
  • Rapid clicking becomes unresponsive. A modal dialog is being opened per click. Use the message bar or a dock widget.

Conclusion

An identify tool is a search rectangle sized in pixels, a provider query, and a ranking rule. Convert the click with toLayerCoordinates() so the CRS is right, derive the radius from mapUnitsPerPixel() so the tolerance feels constant at any zoom, iterate canvas.layers() so only visible layers answer, and report through the message bar so the user can keep clicking.

Frequently Asked Questions

Why does my identify tool never find anything? Most often the click was converted with toMapCoordinates() rather than toLayerCoordinates(), so the coordinates are in the wrong CRS. The other cause is testing the exact point rather than a small search rectangle.

How big should the search tolerance be? Around six screen pixels, converted to map units with canvas.mapUnitsPerPixel() at the moment of the query. Fixing it in map units makes the tool feel unusable at one zoom or the other.

How do I decide which feature to report when several match? Filter to those whose geometry actually contains the click, then sort by ascending area so the smallest and most specific comes first. Fall back to the raw candidates for line and point layers, which never contain a point.

Should I identify every layer in the project? No — iterate canvas.layers(), which returns only the visible layers in draw order, and skip layers outside their scale-visibility range. Identifying something the user cannot see is confusing.