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.
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
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; checkhasVertex/hasEdgeand 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.