Snap Geometries to a Layer in PyQGIS
Snapping is how separately digitized geometries are made to agree. Parcels drawn against an old basemap are pulled onto the new road edges; a zoning layer is aligned with the parcel boundaries it is supposed to follow; a road network with undershoots is closed up; points captured by GPS are moved onto the pipes they describe. Done well, snapping removes slivers and gaps without anybody having to edit vertex by vertex. Done carelessly, it drags features metres out of place and creates new invalid geometries.
This recipe belongs to Data Quality & Topology Validation. It runs native:snapgeometries with a deliberate tolerance and behaviour, snaps a layer to itself, snaps to a grid, and measures every change so the result can be trusted.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- Input and reference layers in the same projected CRS. Snapping in degrees makes the tolerance meaningless.
- Valid geometries in both. Check with a validity report first.
Snap one layer to another
The algorithm takes an input layer, a reference layer, a tolerance in layer units and a behaviour. Everything else follows from those four choices.
import processing
from qgis.core import QgsProject
zoning = QgsProject.instance().mapLayersByName("zoning")[0]
parcels = QgsProject.instance().mapLayersByName("parcels")[0]
result = processing.run("native:snapgeometries", {
"INPUT": zoning,
"REFERENCE_LAYER": parcels,
"TOLERANCE": 0.5, # metres
"BEHAVIOR": 0, # prefer aligning nodes, insert extra vertices
"OUTPUT": "memory:zoning_snapped",
})
snapped = result["OUTPUT"]
QgsProject.instance().addMapLayer(snapped)
print(snapped.featureCount(), "features snapped")
Breakdown: The output is a new layer; the input is untouched, which is what you want until the result has been checked. The tolerance is the maximum distance a vertex may move, in the layer's units. Behaviour 0 first tries to move each input vertex onto a reference vertex, falls back to the closest point on a reference segment, and inserts vertices into the input where the reference has corners inside the tolerance — so a zoning edge that should follow a parcel boundary acquires the parcel's corners. Feature counts and attributes are unchanged; only geometry moves.
Choose the behaviour
The behaviours trade fidelity to the reference against minimal change to the input. Picking the wrong one is the commonest reason snapping disappoints.
The even/odd split is the subtle part: "prefer aligning nodes" moves a vertex onto the nearest reference vertex if one is within tolerance, even if a reference segment is closer; "prefer closest point" moves it to the closest location on the reference, vertex or not. For boundaries that should share corners exactly — zoning on parcels — prefer nodes. For points or lines that should simply lie on the reference — GPS points onto pipes — prefer the closest point.
for behaviour in (0, 1, 2, 3):
out = processing.run("native:snapgeometries", {
"INPUT": zoning, "REFERENCE_LAYER": parcels, "TOLERANCE": 0.5,
"BEHAVIOR": behaviour, "OUTPUT": "memory:"})["OUTPUT"]
vertices = sum(f.geometry().constGet().nCoordinates() for f in out.getFeatures())
print(behaviour, vertices, "vertices")
Breakdown: Comparing vertex counts across behaviours shows how much each one reshapes the input. Behaviours that insert vertices can multiply vertex counts where the reference is detailed; that is the price of following the reference closely. Running all candidates on a sample and looking at the results is quicker than reasoning about them in the abstract.
Snap a layer to itself
Slivers and undershoots inside a single layer are fixed by using the layer as its own reference. Behaviour 7 was designed for this: it snaps vertices to "anchor" nodes so that neighbouring polygons converge on shared vertices instead of chasing each other.
self_snapped = processing.run("native:snapgeometries", {
"INPUT": parcels, "REFERENCE_LAYER": parcels,
"TOLERANCE": 0.2, "BEHAVIOR": 7,
"OUTPUT": "memory:parcels_self_snapped"})["OUTPUT"]
fixed = processing.run("native:fixgeometries", {
"INPUT": self_snapped, "OUTPUT": "memory:parcels_clean"})["OUTPUT"]
QgsProject.instance().addMapLayer(fixed)
Breakdown: Self-snapping with a small tolerance pulls nearly coincident vertices of adjacent polygons together, closing the slivers that gap and overlap checks report as noise. Snapping can collapse a narrow polygon or make a ring touch itself, so running native:fixgeometries afterwards is standard practice. Use the smallest tolerance that closes the slivers — read it off the widths of the slivers you found — because a tolerance close to the size of real features starts merging them.
Snap to a grid
Sometimes the reference is not a layer but a precision. Coordinates with spurious decimals — from reprojection, from CAD exports, from floating-point arithmetic — make exact comparisons fail. Snapping to a grid rounds every vertex to a fixed spacing.
gridded = processing.run("native:snappointstogrid", {
"INPUT": parcels, "HSPACING": 0.001, "VSPACING": 0.001,
"ZSPACING": 0, "MSPACING": 0,
"OUTPUT": "memory:parcels_mm"})["OUTPUT"]
Breakdown: A millimetre grid is invisible at any map scale but makes coordinates that should be identical actually identical, which helps exact-duplicate detection, end-point matching in networks and joins on geometry. Grid snapping can create duplicate consecutive vertices or collapse tiny segments; native:removeduplicatevertices cleans those. Zero Z and M spacing leaves those values alone.
Snap points onto lines
Field data often records assets as points that should lie on a line layer: valves on water mains, signs along roads, sample sites on a river centreline. GPS error leaves them a metre or two off. Snapping with the closest-point behaviour moves each point onto the nearest line within tolerance, and points further away than the tolerance are left alone and can be reviewed separately.
valves = QgsProject.instance().mapLayersByName("valves_gps")[0]
mains = QgsProject.instance().mapLayersByName("water_mains")[0]
on_line = processing.run("native:snapgeometries", {
"INPUT": valves, "REFERENCE_LAYER": mains,
"TOLERANCE": 3.0, "BEHAVIOR": 1, # closest point on the reference
"OUTPUT": "memory:valves_on_mains"})["OUTPUT"]
original = {f["valve_id"]: f.geometry() for f in valves.getFeatures()}
unmoved = [f["valve_id"] for f in on_line.getFeatures()
if f.geometry().equals(original[f["valve_id"]])]
print(len(unmoved), "valves further than 3 m from any main")
Breakdown: For points there are no segments to align, so the difference between behaviours reduces to "nearest reference vertex" versus "closest point anywhere on the line"; the closest point is almost always what field data needs. Points that did not move were outside the tolerance — either genuinely off the network, or the network is incomplete there — and deserve a look rather than a bigger tolerance. The network snapping recipe covers the related case of snapping origins and destinations before routing, where the snapped location also splits a network edge.
Measure what moved
Snapping should never be a black box. Comparing each feature's geometry before and after gives the maximum displacement and the change in area, so an unexpectedly large move is caught before the result replaces the original.
from qgis.core import QgsFeatureRequest
before = {f["parcel_id"]: f.geometry() for f in parcels.getFeatures()}
flags = []
for f in fixed.getFeatures():
old = before.get(f["parcel_id"])
if old is None:
continue
new = f.geometry()
moved = old.hausdorffDistance(new)
area_change = abs(new.area() - old.area()) / old.area() if old.area() else 0
if moved > 0.2 or area_change > 0.005:
flags.append((f["parcel_id"], round(moved, 3), round(area_change * 100, 3)))
print(len(flags), "parcels moved more than expected")
for row in flags[:10]:
print(row)
Breakdown: The Hausdorff distance is the largest distance from any point of one geometry to the other — a direct measure of the worst displacement. It should never exceed the tolerance on a simple snap; when it does, fixgeometries reshaped the feature substantially, which deserves a look. The relative area change catches the opposite case: a small move that still changes a narrow parcel's area noticeably. Matching features by a business key rather than feature id keeps the comparison correct even if the output layer renumbered features.
Replace the original safely
When the audit is clean, write the snapped geometries back. Updating geometry in place keeps feature ids, relations and styling intact.
from qgis.core import edit
new_geoms = {f["parcel_id"]: f.geometry() for f in fixed.getFeatures()}
flagged = {pid for pid, *_ in flags}
with edit(parcels):
for f in parcels.getFeatures():
pid = f["parcel_id"]
if pid in new_geoms and pid not in flagged:
parcels.changeGeometry(f.id(), new_geoms[pid])
Breakdown: Writing geometries back rather than replacing the layer preserves everything attached to it — joins, relations, form configuration, edit history. Flagged features keep their old geometry until someone has reviewed them. Do this inside edit() so a failure leaves the original untouched; for interactive work, wrapping it in an edit command makes the whole snap undoable in one step, as shown in undoing edits with the edit buffer.
QGIS version compatibility
native:snapgeometries exists from QGIS 3.0; behaviours 4 to 7 were added during the 3.x series and are present on 3.34 LTR, 3.40 LTR and QGIS 4. hausdorffDistance and native:snappointstogrid are available on all current releases. Confirm behaviour numbers on your version with processing.algorithmHelp("native:snapgeometries").
Troubleshooting
- Nothing moved. The tolerance is in the wrong units (the layer is in degrees), or input and reference are in different CRSs.
- Polygons collapsed or became invalid. The tolerance is too large relative to feature width; reduce it and run
native:fixgeometries. - Vertex counts exploded. A behaviour that inserts vertices was used against a very detailed reference; try 2 or 3.
- Neighbouring polygons still have slivers after self-snapping. Use behaviour 7 and run a second pass with the same tolerance.
Conclusion
Snap with an explicit tolerance in metres and a behaviour chosen for the job — nodes for shared boundaries, closest point for features that lie on a reference, end points for networks, anchors for self-snapping — fix geometries afterwards, audit every feature's displacement and area change, and write back only the changes that pass.
Frequently Asked Questions
Is snapping the same as the digitizing snapping options?
No. Digitizing snapping guides the cursor while editing; native:snapgeometries moves existing geometry in bulk.
Can I snap points to lines? Yes. Use a point layer as input, a line layer as reference and behaviour 1 or 3 for the closest point.
Does snapping preserve attributes? Yes. Only geometry changes.
How do I choose the tolerance? From the data's accuracy and from the size of the gaps you measured — the smallest value that closes them.