Snap Points to a Network in PyQGIS
Snapping is where routing quietly stops being about the network and starts being about your points. A postcode centroid, a building footprint centre or a GPS fix does not sit on a road, so QGIS moves it onto one — and every cost it reports afterwards is measured from the moved position. Get that wrong and the numbers are not slightly off, they are answering a different question.
This recipe belongs to Network Analysis & Routing in PyQGIS. It covers how makeGraph ties points, how to measure and report the offset it introduced, snapping a whole layer at once, and the cases where the nearest edge is the wrong edge.
Prerequisites
- QGIS 3.34 LTR or newer, network and points in the same projected CRS.
- The graph basics from building a network graph.
How makeGraph ties points
makeGraph takes a list of points and returns a list of the same length, in the same order, holding the position each point was moved to.
from qgis.analysis import (
QgsVectorLayerDirector, QgsNetworkDistanceStrategy, QgsGraphBuilder,
)
from qgis.core import QgsPointXY
wanted = [QgsPointXY(445120, 187330), QgsPointXY(447980, 189010)]
director = QgsVectorLayerDirector(
roads, -1, "", "", "", QgsVectorLayerDirector.DirectionBoth
)
director.addStrategy(QgsNetworkDistanceStrategy())
builder = QgsGraphBuilder(roads.sourceCrs(), True, 0.5)
tied = director.makeGraph(builder, wanted)
graph = builder.graph()
for original, snapped in zip(wanted, tied):
print(f"moved {original.distance(snapped):.1f} m")
Breakdown: The snapped position is the nearest point on the nearest edge — a perpendicular foot, not the nearest existing vertex — and the builder splits that edge so the snapped position becomes a real vertex in the graph. That splitting is why the tie list must be passed at build time rather than afterwards: a point tied later would have nothing to attach to. Because the returned list preserves order, zip against the originals is the natural way to compare, and it is the only way to recover which point went where.
Two consequences follow. First, graph.findVertex() only matches the tied coordinates, never your originals — a mismatch there returns -1 and is the most common beginner error in this API. Second, tying many points makes the graph slightly larger, since each split adds a vertex and replaces one edge with two; on a few thousand points that is negligible, and it is one more reason to tie every point you will ever need in a single build.
Reporting the offset honestly
The offset is not an error to be eliminated — a building genuinely is twenty metres from the road — but it is information the person reading your output needs.
def snap_report(originals, snapped, warn_at=50.0):
offsets = [o.distance(s) for o, s in zip(originals, snapped)]
worst = max(range(len(offsets)), key=offsets.__getitem__)
print(f"median offset {sorted(offsets)[len(offsets) // 2]:.1f} m, "
f"max {offsets[worst]:.1f} m at index {worst}")
return [i for i, d in enumerate(offsets) if d > warn_at]
flagged = snap_report(wanted, tied)
print(len(flagged), "points snapped further than 50 m")
Breakdown: The median rather than the mean is the right summary, because a handful of remote points drag a mean upward and hide the fact that most points snapped tidily. The threshold belongs to the domain: fifty metres is generous for urban addresses and absurd for a footpath network, where five would be more like it. Returning indices rather than points keeps the caller able to look up whatever identifier the original features carried. Adding each offset to the corresponding route cost is a defensible correction when the ground between is traversable, and misleading when it is a canal — which is why the flagged list is worth eyeballing on a map rather than automatically compensating for.
Snapping a whole layer
sites = QgsProject.instance().mapLayersByName("delivery_sites")[0]
originals, ids = [], []
for feature in sites.getFeatures():
originals.append(feature.geometry().asPoint())
ids.append(feature["site_id"])
tied = director.makeGraph(builder, originals)
graph = builder.graph()
vertex_for = {
site_id: graph.findVertex(point)
for site_id, point in zip(ids, tied)
}
missing = [k for k, v in vertex_for.items() if v == -1]
print("unresolved:", missing)
Breakdown: Collecting the identifiers in a parallel list is what lets you get back from graph vertices to business objects afterwards, and it is worth doing even when the ids look like they will be sequential — feature ids are not stable across providers. A -1 in the mapping means findVertex failed on a point that makeGraph returned, which should not happen and, when it does, means the point list was mutated between the two calls. Building the whole dictionary once and reusing it for every subsequent query is the pattern that makes an origin-destination matrix practical.
Where you want a snapped layer as a durable artefact — to check in a review, or to feed another tool — the Processing algorithm does that:
processing.run("native:snapgeometries", {
"INPUT": sites,
"REFERENCE_LAYER": roads,
"TOLERANCE": 60,
"BEHAVIOR": 1,
"OUTPUT": "/data/output/sites_snapped.gpkg",
})
Breakdown: BEHAVIOR 1 snaps to the nearest point on a segment rather than only to existing vertices, which is what matches the router's own behaviour; behaviour 0 snaps to vertices only and will move a point to the end of a street. TOLERANCE is a maximum move, so points further than 60 m stay where they are — which makes the algorithm safe to run over a mixed layer and leaves the remote points identifiable by comparing before and after.
When nearest is wrong
Three situations produce a geometrically correct snap and a practically wrong one, and none of them is detectable from the offset alone.
A point in the middle of a city block snaps to whichever of the surrounding streets is marginally nearer, which may not be the one the entrance is on. A point beside a motorway snaps onto the motorway, from which there is no access — this one is common enough with roadside sites that excluding limited-access classes from the network before tying is a standard mitigation. And a point separated from the nearest road by a river, railway or wall snaps across the barrier, producing a route that begins with an impossible thirty metres.
The general fix is the same in all three cases: restrict what can be snapped to. Filter the network with a subset string to the classes that are genuinely accessible, tie against that, and route on the full network afterwards if you need to. It costs a second graph and removes an entire category of silent error.
accessible = QgsProject.instance().mapLayersByName("roads")[0]
accessible.setSubsetString("highway NOT IN ('motorway', 'motorway_link', 'trunk')")
snap_builder = QgsGraphBuilder(accessible.sourceCrs(), True, 0.5)
snap_tied = director.makeGraph(snap_builder, originals)
accessible.setSubsetString("")
Breakdown: Setting a subset string on the layer, building, then clearing it is the least invasive way to restrict what points may snap to — no copies, no temporary files. Clearing it afterwards matters, because a subset string left in place changes what every later build and every later render sees, and it persists in the project file. Where the same script does several things with the layer, taking a filtered copy with native:extractbyexpression is the safer form even though it costs a temporary layer.
QGIS version compatibility
makeGraph's tie behaviour has been stable since QGIS 3.0. native:snapgeometries gained its current BEHAVIOR options in 3.4; earlier releases offered fewer modes. Nothing here changed in 3.40 or 3.44.
Troubleshooting
findVertexreturns −1 for every point. Your originals were passed instead of the tied list.- All points snapped to the same vertex. They are all nearer to one edge than to anything else — often a sign the network is a single long unsplit line.
- Offsets are in the hundreds of thousands. Points and network are in different CRSs, and the numbers are the distance between two projections.
- A route starts by crossing a river. Snapped across a barrier; restrict the snapping network.
- Pre-snapping moved nothing. The tolerance is smaller than the offsets, which is the algorithm behaving correctly.
- Two identical points give different vertices. Floating-point coordinates that differ in the last digit; round before tying if you rely on identity.
Conclusion
Tie every point in one makeGraph call, keep a parallel list of identifiers, and always look up the returned positions rather than your own. Measure the offsets, report the median and the maximum, and restrict the snappable network wherever motorways or barriers can catch a point. The snap is the join between your data and the network, and it deserves the same scrutiny as either side of it.
Frequently Asked Questions
Can I snap to a specific edge rather than the nearest?
Not through makeGraph. Filter the network to the edges you will accept and tie against that subset.
Does snapping change the network for other queries? It splits the tied edges in that graph, so yes within that build — which is harmless, since the split preserves total length and connectivity.
How do I snap points to the nearest junction instead? Extract the network's endpoints as a point layer, then run a nearest-neighbour join against it. That answers a different question and is occasionally the one you want.
Is the offset included in the route cost? No. The cost begins at the snapped position. Add it yourself if the last few metres matter.