Snap to Features with QgsSnappingUtils in PyQGIS

A custom map tool that takes the raw click position produces coordinates that are almost right, which in GIS is the same as wrong — a vertex placed 40 cm from the one it should coincide with breaks topology, routing and every subsequent geometric test. The built-in editing tools snap, and the machinery that lets them do it is available to any tool you write.

This recipe belongs to Custom Map Tools & Canvas Interaction. It covers configuring snapping, reading a match, telling the user what they hit, and restricting snapping to particular layers.

What the click turns intoClicking away from any feature returns the raw map position. Clicking near an existing vertex within the tolerance returns that vertex exactly. Clicking near an edge but away from its vertices returns the perpendicular foot on that edge, which is a new position lying exactly on the line.Same click, three different answersno matchraw position returnedvertex matchthe existing vertex, exactlysegment matchon the edge, a new pointthe orange dot is where the cursor was; the ring is the tolerancevertex snapping wins over segment snapping when both are in range

Prerequisites

  • QGIS 3.34 LTR or newer, with a GUI — snapping is a canvas facility.
  • A custom map tool, or a subclass of QgsMapTool — see creating a custom map tool.

Configure the snapping

from qgis.core import QgsSnappingConfig, QgsTolerance, QgsProject, Qgis

utils = iface.mapCanvas().snappingUtils()

config = QgsSnappingConfig(QgsProject.instance())
config.setEnabled(True)
config.setMode(Qgis.SnappingMode.AllLayers)
config.setTypeFlag(
    Qgis.SnappingTypes(Qgis.SnappingType.Vertex | Qgis.SnappingType.Segment)
)
config.setTolerance(12)
config.setUnits(QgsTolerance.Pixels)

utils.setConfig(config)

Breakdown: Constructing the config from the project rather than empty inherits the project's own snapping settings as a starting point, so a user who configured snapping in the GUI is not overridden wholesale. The mode chooses which layers participate: AllLayers, ActiveLayer, or AdvancedConfiguration for per-layer control. Type flags combine with a bitwise OR, and including both vertex and segment gives the behaviour users expect — vertices win where both are in range. A tolerance in pixels rather than map units is almost always right for interactive work, because it stays constant on screen as the user zooms; map units make snapping impossible at one zoom and indiscriminate at another.

Changing the canvas's config affects the whole application, so a well-behaved plugin either restores the previous config on deactivation or applies its own without persisting it:

class SnappingTool(QgsMapTool):
    def activate(self):
        self.previous = self.canvas().snappingUtils().config()
        self.canvas().snappingUtils().setConfig(self.config)

    def deactivate(self):
        self.canvas().snappingUtils().setConfig(self.previous)

Breakdown: activate and deactivate are called by the canvas when the tool is set and unset, which makes them the natural place for this. Saving and restoring means the user's snapping settings survive using your tool, which is the difference between a plugin that feels integrated and one that quietly changes the application's behaviour.

Read the match

from qgis.gui import QgsMapTool
from qgis.core import QgsPointXY


class PickTool(QgsMapTool):
    def canvasReleaseEvent(self, event):
        utils = self.canvas().snappingUtils()
        match = utils.snapToMap(event.pos())

        if not match.isValid():
            point = self.toMapCoordinates(event.pos())
            print("no snap:", point.x(), point.y())
            return

        point = match.point()
        layer = match.layer()
        print(f"snapped to {layer.name()} feature {match.featureId()} at "
              f"{point.x():.3f}, {point.y():.3f}")
        print("vertex" if match.hasVertex() else "edge",
              f"{match.distance():.2f} map units away")

Breakdown: snapToMap accepts either a screen position (event.pos()) or a map point, and returns a QgsPointLocator.Match that is always non-null but may be invalid — checking isValid() rather than testing for None is what catches "nothing was near enough". match.point() is the snapped position, which for a vertex match is the exact stored coordinate and for a segment match is a computed foot on the line. hasVertex() and hasEdge() distinguish the two, and vertexIndex() on a vertex match tells you which vertex of the geometry it was — which is what an editing tool needs to move it rather than add to it.

In a tool that extends QgsMapToolEdit or uses QgsMapMouseEvent, the event itself can do the snapping:

    def canvasMoveEvent(self, event):
        point = event.snapPoint()

Breakdown: QgsMapMouseEvent.snapPoint() runs the canvas's snapping utils and returns the snapped map point in one call, caching the result on the event so repeated calls are free. It is the concise form for a tool that only needs the position and not the layer or feature it came from. Note that it applies the canvas configuration, not any private one, which is another argument for configuring the canvas in activate rather than keeping a separate config.

Show the user what will happen

Feedback before the click, not afterWithout feedback the user clicks and hopes, discovering only afterwards whether the point snapped where they intended. Drawing a marker at the snapped position as the cursor moves shows the candidate before the click, and changing its shape between a vertex and an edge match shows which kind of snap is on offer.A marker on move is the whole of good snapping UXno feedbackclick and find outvertex marker on movethe target is visible firsta cross for a vertex, a box for an edge — the shape carries the information

from qgis.gui import QgsVertexMarker
from qgis.PyQt.QtGui import QColor


class PickTool(QgsMapTool):
    def __init__(self, canvas):
        super().__init__(canvas)
        self.marker = QgsVertexMarker(canvas)
        self.marker.setColor(QColor("#0f766e"))
        self.marker.setPenWidth(3)
        self.marker.hide()

    def canvasMoveEvent(self, event):
        match = self.canvas().snappingUtils().snapToMap(event.pos())
        if match.isValid():
            self.marker.setIconType(
                QgsVertexMarker.ICON_X if match.hasVertex() else QgsVertexMarker.ICON_BOX
            )
            self.marker.setCenter(match.point())
            self.marker.show()
        else:
            self.marker.hide()

    def deactivate(self):
        self.marker.hide()
        super().deactivate()

Breakdown: Keeping the marker on self is required — a QgsVertexMarker created as a local is collected while the canvas still points at it, which crashes rather than merely disappearing, as described in object ownership and crashes. Creating one marker and moving it, rather than creating one per mouse move, is what keeps this smooth. Changing the icon between a cross and a box communicates the match type without any text. Hiding it in deactivate stops a stale marker sitting on the canvas after the tool is put away.

Restricting which layers snap

config.setMode(Qgis.SnappingMode.AdvancedConfiguration)

roads = QgsProject.instance().mapLayersByName("roads")[0]
settings = QgsSnappingConfig.IndividualLayerSettings(
    True,
    Qgis.SnappingTypes(Qgis.SnappingType.Vertex),
    10,
    QgsTolerance.Pixels,
    0.0,
    0.0,
)
config.setIndividualLayerSettings(roads, settings)
utils.setConfig(config)

Breakdown: Advanced configuration is per layer, so a tool can snap to the road centrelines and ignore the twenty other layers in the project — which is both faster and far less frustrating for the user than snapping to whatever happens to be underneath. The settings constructor's arguments are enabled, type flags, tolerance, units, then the minimum and maximum scale at which the layer participates (0 meaning no limit). Layers not given individual settings do not snap in this mode, which is the behaviour you want.

Using the match, not just its position

The match carries more than a coordinate, and the extra fields are what let a tool do something useful with what the user pointed at.

    def canvasReleaseEvent(self, event):
        match = self.canvas().snappingUtils().snapToMap(event.pos())
        if not match.isValid() or match.layer() is None:
            return

        layer = match.layer()
        feature = next(layer.getFeatures(
            QgsFeatureRequest().setFilterFid(match.featureId())
        ), None)
        if feature is None:
            return

        if match.hasVertex():
            geometry = feature.geometry()
            print(f"vertex {match.vertexIndex()} of "
                  f"{len(geometry.asPolyline() or [])} on {layer.name()}")
        else:
            print(f"on the edge starting at vertex {match.vertexIndex()}")

Breakdown: setFilterFid fetches exactly one feature by id and is far cheaper than iterating and comparing. vertexIndex() means slightly different things for the two match types: for a vertex match it is that vertex's index, and for an edge match it is the index of the segment's first vertex — which is what you need to insert a new vertex in the right place. Guarding on match.layer() being present matters because intersection matches, when enabled, have no owning layer and would otherwise raise on getFeatures.

Having the feature as well as the position is what lets a tool report a road's name as the user hovers, prefill a form with the parcel that was clicked, or refuse a click on a feature that is not a valid target — all of which make a tool feel like it understands the data rather than merely the geometry.

QGIS version compatibility

QgsSnappingUtils and QgsSnappingConfig have been present since QGIS 3.0. The mode and type enums moved into the scoped Qgis namespace in 3.26 — on earlier releases they are QgsSnappingConfig.AllLayers and QgsSnappingConfig.Vertex, with the combined VertexAndSegment value now deprecated in favour of the flag OR shown above. QgsMapMouseEvent.snapPoint() lost an earlier argument in 3.0 and has been stable since.

Troubleshooting

  • Nothing ever snaps. The config was built but not passed to setConfig, or snapping is not enabled on it.
  • Snapping works at one zoom and not another. The tolerance is in map units rather than pixels.
  • The marker crashes QGIS. It was not kept referenced on the tool.
  • Snapping picks the wrong layer. Mode is AllLayers; switch to advanced configuration and name the layers.
  • match.layer() is None. The match came from a source without a layer, such as an intersection match; check hasVertex/hasEdge and guard.
  • The user's snapping settings changed after using the tool. No restore in deactivate.

Conclusion

Configure snapping in activate and restore it in deactivate, use pixel tolerance, check isValid() on every match, and draw a marker as the cursor moves so the user sees the candidate before committing. A custom tool that snaps properly is indistinguishable from a built-in one, and the difference is about twenty lines.

Frequently Asked Questions

Can I snap to a layer that is not visible? In advanced configuration, yes — participation is per layer and independent of visibility. In the other modes only visible layers are considered.

How do I snap to intersections between layers?QgsSnappingConfig.setIntersectionSnapping(True) adds intersection matches. They have no owning feature, so guard on match.layer() being present.

Does snapping work on a raster? No. It operates on vector geometry through a spatial locator per layer.

Is snapping expensive on a large layer? The locator builds an index per layer on first use, so the first snap after loading is slower and the rest are fast. Restricting to the layers you need keeps that cost small.