Find the Shortest Path Between Points in PyQGIS

Routing between two points is the question everyone asks first, and it has two good answers in QGIS: a Processing algorithm that does the whole thing in one call, and the graph API that does it in twenty lines but lets you ask again ten thousand times without rebuilding anything. Which one is right depends entirely on how many routes you need.

This recipe belongs to Network Analysis & Routing in PyQGIS. It covers both routes, reconstructing the path geometry, reading the cost, and the three checks that catch a route that is confidently wrong.

The route runs between snapped positions, not your pointsA start point beside the network is snapped perpendicular onto the nearest edge, and so is the end point. The route runs from one snapped position through intermediate junctions to the other. The gap between each original point and its snapped position is not part of the route and not part of the reported cost.The reported cost starts at the snapped pointyour start pointyour end pointsnapped onto the edgethe dashed segments are yours to account for — QGIS does not include themon a point 400 m from the nearest road, that omission is the whole answer

Prerequisites

  • QGIS 3.34 LTR or newer, in a projected CRS.
  • A line network split at junctions — see building a network graph.
  • Two points, as coordinates or as features in a layer.

One route: the Processing algorithm

import processing
from qgis.core import QgsProject

roads = QgsProject.instance().mapLayersByName("roads")[0]

result = processing.run("native:shortestpathpointtopoint", {
    "INPUT": roads,
    "STRATEGY": 0,
    "START_POINT": "445120,187330 [EPSG:27700]",
    "END_POINT": "447980,189010 [EPSG:27700]",
    "TOLERANCE": 0.5,
    "DEFAULT_DIRECTION": 2,
    "OUTPUT": "TEMPORARY_OUTPUT",
})

route = result["OUTPUT"]
feature = next(route.getFeatures())
print(feature["cost"], "map units;", feature.geometry().length(), "m drawn")

Breakdown: STRATEGY is 0 for shortest by distance and 1 for fastest by time, where the fastest option needs SPEED_FIELD or DEFAULT_SPEED set or it silently assumes a uniform speed. DEFAULT_DIRECTION of 2 means "both", which is what you want unless the layer's direction field is complete. The output is a single-feature line layer carrying start, end and cost attributes. Note the two lengths printed: cost is the graph cost, the geometry length is the drawn length, and where the network's lines are curved these differ, because the graph measures along vertices while the output geometry is reconstructed from the source features.

Routing from one point to many is the same call under a different name:

processing.run("native:shortestpathpointtolayer", {
    "INPUT": roads,
    "STRATEGY": 0,
    "START_POINT": "445120,187330 [EPSG:27700]",
    "END_POINTS": destinations_layer,
    "TOLERANCE": 0.5,
    "OUTPUT": "TEMPORARY_OUTPUT",
})

Breakdown: This builds the graph once and runs Dijkstra once from the start, then reads a route to every destination out of the same tree — which is far cheaper than a loop over the point-to-point algorithm, and is the algorithm to reach for whenever one origin serves many destinations. Reverse the roles with native:shortestpathlayertopoint when many origins share one destination, which is the shape of most "nearest facility" questions.

Many routes: the graph API

One Dijkstra run answers every destinationRunning Dijkstra from an origin fills a predecessor array in which each reachable vertex records the edge used to reach it. A route to any destination is recovered by following those predecessors backwards to the origin, so a thousand destinations cost one run and a thousand cheap walks.Every arrow points back towards the originoriginthe predecessor tree, drawncost of the workbuild graph — oncedijkstra — once per originwalk back — once per pairthe walk is a few dozenarray lookupsso ask for all of themre-running the algorithm per pair rebuilds the graph every time

from qgis.analysis import (
    QgsVectorLayerDirector, QgsNetworkDistanceStrategy,
    QgsGraphBuilder, QgsGraphAnalyzer,
)
from qgis.core import QgsPointXY, QgsGeometry

start = QgsPointXY(445120, 187330)
end = QgsPointXY(447980, 189010)

director = QgsVectorLayerDirector(
    roads, -1, "", "", "", QgsVectorLayerDirector.DirectionBoth
)
director.addStrategy(QgsNetworkDistanceStrategy())

builder = QgsGraphBuilder(roads.sourceCrs(), True, 0.5)
tied = director.makeGraph(builder, [start, end])
graph = builder.graph()

start_id = graph.findVertex(tied[0])
end_id = graph.findVertex(tied[1])
tree, costs = QgsGraphAnalyzer.dijkstra(graph, start_id, 0)

Breakdown: tied holds the snapped positions in the same order as the points you passed, and those are the only coordinates findVertex will match. Everything expensive has now happened: tree and costs are arrays indexed by vertex id, and reading a route out of them is arithmetic. If you have a thousand destinations, tie all thousand in this one makeGraph call and look each up against the same tree.

Reconstructing the geometry

if tree[end_id] == -1:
    raise SystemExit("no route: the destination is in a different fragment")

points = [graph.vertex(end_id).point()]
current = end_id
while current != start_id:
    edge = graph.edge(tree[current])
    current = edge.fromVertex()
    points.insert(0, graph.vertex(current).point())

geometry = QgsGeometry.fromPolylineXY(points)
print(f"{costs[end_id]:.0f} m cost, {len(points) - 1} edges, "
      f"{geometry.length():.0f} m drawn")

Breakdown: The -1 guard must come before the loop, not inside it — without it an unreachable destination spins forever rather than raising, because tree[current] stays -1 and current never changes. Inserting at the front rather than appending and reversing is a matter of taste at these lengths. The cost and the drawn length agreeing to within a metre or two is a good sign; a large discrepancy means the source lines have many intermediate vertices that the graph does not see, and the drawn length is the trustworthy one.

Writing the route out as a layer

A route that only exists as a QgsGeometry cannot be styled, labelled or exported, so most scripts want it in a memory layer alongside its cost.

from qgis.core import QgsVectorLayer, QgsFeature, QgsField, QgsProject
from qgis.PyQt.QtCore import QVariant

routes = QgsVectorLayer(
    "LineString?crs=" + roads.sourceCrs().authid(), "routes", "memory"
)
provider = routes.dataProvider()
provider.addAttributes([
    QgsField("origin", QVariant.String),
    QgsField("destination", QVariant.String),
    QgsField("cost_m", QVariant.Double),
])
routes.updateFields()

feature = QgsFeature(routes.fields())
feature.setGeometry(geometry)
feature["origin"] = "depot"
feature["destination"] = "site_14"
feature["cost_m"] = float(costs[end_id])
provider.addFeature(feature)
routes.updateExtents()

QgsProject.instance().addMapLayer(routes)

Breakdown: Constructing the feature with QgsFeature(routes.fields()) rather than a bare QgsFeature() is what makes the named attribute assignment work — without the field set, feature["cost_m"] raises. updateFields() after adding attributes to the provider is required before the layer knows about them, and updateExtents() after adding features is what stops "zoom to layer" landing somewhere useless. Casting the cost to float matters more than it looks: the values coming out of costs are numpy-ish scalars in some builds and the OGR writer rejects them when the memory layer is later exported. Building this layer inside the loop and adding every route to it gives you a single output that a graduated renderer can colour by cost immediately.

Three checks that catch a wrong route

Is the destination reachable at all? Covered above, and the single most common failure.

Is the snapped position sensible? A point 400 m from the nearest road snaps to that road and reports a cost that omits the 400 m entirely.

for label, original, snapped in (("start", start, tied[0]), ("end", end, tied[1])):
    offset = original.distance(snapped)
    if offset > 50:
        print(f"warning: {label} snapped {offset:.0f} m onto the network")

Breakdown: Fifty metres is a reasonable threshold for road routing and far too generous for a footpath network — pick it from what the network represents. Reporting rather than raising is usually right, because the answer is still the best available; what matters is that nobody downstream believes the cost is a door-to-door distance. Adding the two offsets onto the reported cost is a defensible approximation when the terrain between point and network is walkable, and indefensible when it is a river.

Does the route make sense as a shape? A route far longer than the straight-line distance usually means a missing connection.

detour = geometry.length() / start.distance(end)
print(f"detour ratio {detour:.2f}")

Breakdown: On a dense urban network this lands between 1.2 and 1.4; on a rural one it can legitimately reach 2. Above about 3, look at the route on the map — it is nearly always going the long way round a break in the network rather than genuinely following a winding valley. This one number, logged per route, catches more data problems than any amount of visual inspection across a batch run.

QGIS version compatibility

native:shortestpathpointtopoint and its two siblings have carried the same parameter names since 3.0. In the graph API, fromVertex()/toVertex() replaced inVertex()/outVertex() in 3.24; the older names still exist but are deprecated. The cost attribute on the algorithm's output is in layer units for strategy 0 and in seconds for strategy 1.

Troubleshooting

  • Empty output layer from the algorithm. No route exists between the snapped positions — fragmented network, or a one-way field making the destination unreachable.
  • The route ignores an obvious shortcut. That road's endpoints do not meet the network within the tolerance, or its direction attribute forbids the travel direction.
  • findVertex returns −1. Your own coordinates were passed instead of the tied points from makeGraph.
  • An infinite loop while walking the tree. No -1 guard before the loop.
  • The cost is much smaller than expected. Points snapped a long way onto the network, and the gap is not in the cost.
  • Fastest and shortest give identical routes. No speed strategy was added, so both criteria are distance.

Conclusion

Use the Processing algorithm for a single route or for one-to-many, and the graph API when many origins and destinations share a network. Whichever you use, guard for unreachability, check how far the points snapped, and log the detour ratio — those three lines turn routing from something that produces numbers into something that produces numbers you can defend.

Frequently Asked Questions

Can I force a route through an intermediate point? Run two queries and concatenate them. There is no waypoint parameter, and chaining segments is the standard approach — note that it forbids the route from passing through the waypoint twice, which is occasionally what you want and occasionally not.

How do I get the road names along a route? The graph holds no link back to the features, so intersect the route geometry with the source layer afterwards using a spatial join, or use the Processing algorithm whose output is reconstructed from the source features.

Is Dijkstra the only algorithm available? Yes, through QgsGraphAnalyzer. There is no A* and no contraction hierarchy, which is why very large networks are better served by a dedicated engine.

Can I route on a network with barriers or closures? Filter them out of the layer before building the graph — a subset string excluding closed roads is the simplest form, and it means the closure is expressed in data rather than in code.