Network Analysis & Routing in PyQGIS
QGIS will route across any line layer you give it, and that is both the appeal and the trap. There is no road database behind the scenes, no turn restrictions, no traffic — just the geometry you supply and the rules you describe. Feed it a clean topological network and it produces answers that stand up. Feed it a cartographic road layer where the dual carriageways never quite touch, and it produces answers that look plausible and are wrong.
This guide sits inside Spatial Data Processing & Automation and covers the whole subject: how a line layer becomes a graph, how to describe direction and cost, how to run shortest paths and service areas from Python, and — the part that decides whether any of it works — how snapping and topology tolerance behave.
What the routing API expects from your data
The single most important thing to understand about network analysis in QGIS is that it does not clean your data. QgsGraphBuilder walks the line layer and creates a vertex at every line endpoint. Two lines are connected if — and only if — their endpoints coincide within the topology tolerance you set. Lines that cross without a shared vertex are not connected; a bridge and the road under it are correctly not connected, which is a feature, but so is a junction where the digitising was sloppy, which is not.
That has three consequences that shape every routing project:
Roads split at junctions are required. A single polyline running the length of a street with side roads meeting it in the middle produces a graph in which none of those side roads connect, because their endpoints land in the middle of the street's geometry rather than at its ends. Running native:splitwithlines on the layer against itself, or native:explodelines, fixes it.
Direction is data, not geometry. QGIS reads which way an edge may be travelled from an attribute you nominate, comparing its value against three strings you supply for forward, backward and both. There is no convention it assumes.
Costs come from strategies. Distance is free — QGIS computes it from the geometry. Anything else, most usefully travel time, comes from a speed attribute you provide.
Building the graph
from qgis.analysis import (
QgsVectorLayerDirector,
QgsNetworkDistanceStrategy,
QgsGraphBuilder,
)
from qgis.core import QgsProject
roads = QgsProject.instance().mapLayersByName("roads")[0]
director = QgsVectorLayerDirector(
roads,
-1, # direction field index; -1 means "no field, use the default"
"", # value meaning forward only
"", # value meaning backward only
"", # value meaning both
QgsVectorLayerDirector.DirectionBoth,
)
director.addStrategy(QgsNetworkDistanceStrategy())
builder = QgsGraphBuilder(roads.sourceCrs())
tied = director.makeGraph(builder, [start_point, end_point])
graph = builder.graph()
Breakdown: The six director arguments are positional and easy to get wrong; passing -1 for the field index makes the three value strings irrelevant and treats every edge as bidirectional, which is the right starting point while you are still checking that the topology works at all. makeGraph() does two things at once: it builds the graph, and it snaps the points you pass onto the nearest edge, returning the snapped positions in the same order. Those returned points, not your originals, are what you look up in the graph afterwards — a distinction covered in snapping points to a network.
QgsGraphBuilder takes an optional topology tolerance as its third argument. Left at zero, endpoints must coincide exactly; set to 0.5 in a metric CRS, endpoints within half a metre are merged into one vertex. That single number rescues most real-world road layers and, set too high, silently welds together roads that should not meet.
Two properties of the returned graph are worth knowing before you use it. It is directed: a two-way street becomes two edges, one each way, so the edge count is roughly twice the segment count and a graph over a 200,000-segment road layer holds around 400,000 edges. And it is detached from the source layer: once built, it holds no reference to features, so you cannot ask an edge which road it came from. If you need that link — to report street names along a route, say — record it yourself by keeping a parallel list as you walk the layer, or accept that you will re-intersect the output geometry with the source layer afterwards.
The build itself is the expensive step. On a moderate city network it takes a few seconds; on a national road layer it takes minutes and a great deal of memory. That cost profile is the reason the graph API and the Processing algorithms occupy different niches, which the comparison further down makes concrete.
Direction, one-way streets and turn penalties
Wiring a real one-way field is the same constructor with the strings filled in:
field_index = roads.fields().indexOf("oneway")
director = QgsVectorLayerDirector(
roads, field_index, "F", "T", "B",
QgsVectorLayerDirector.DirectionBoth,
)
Breakdown: The comparison is exact and case-sensitive against the attribute rendered as a string, so "F" will not match "f" or " F". The default direction is applied to everything that matches none of the three — including nulls, which in most road datasets are the overwhelming majority, since only the one-way streets carry a value. Choosing DirectionBoth as the default is therefore usually right, and choosing DirectionForward by mistake makes every unmarked street one-way, producing routes that wander absurdly.
What QGIS does not model is turn restrictions. No-left-turns, banned manoeuvres and turn penalties have no representation in this API. Where they matter — urban delivery routing, in particular — the honest answer is that QGIS's network analysis is the wrong tool and a dedicated routing engine is the right one.
Cost strategies: distance and time
A strategy attaches a number to each edge, and the analyzer minimises the sum along the path. QgsNetworkDistanceStrategy uses geometric length in the layer's units. QgsNetworkSpeedStrategy divides that length by a speed read from an attribute, producing a travel time.
from qgis.analysis import QgsNetworkSpeedStrategy
speed_index = roads.fields().indexOf("speed_kph")
strategy = QgsNetworkSpeedStrategy(speed_index, 50.0, 1000.0 / 3600.0)
director.addStrategy(strategy)
Breakdown: The three arguments are the field index, the default speed used where the field is null, and a multiplier converting the field's units into layer-units-per-second. For a layer in metres with speeds in km/h the multiplier is 1000/3600; get it wrong and every travel time is out by a constant factor, which is invisible in a route and fatal in a service area. Adding several strategies is allowed — each becomes a numbered criterion, and the analyzer takes an index to say which one to minimise, which is how one graph answers both "shortest" and "fastest". Adding speed and travel cost to a network works through the unit arithmetic in detail.
A subtlety that catches people out: adding two strategies does not give you a graph that can be queried on either criterion independently in every sense. Each edge carries a cost vector, and the analyzer's criterion index picks which element to minimise, so the routing is genuinely independent per criterion. But the strategies are evaluated when the graph is built, which means changing a speed assumption requires rebuilding. Where you want to compare several speed scenarios, add several speed strategies up front — one per scenario — and switch criterion index at query time. That builds once and answers all of them.
Running the analysis
With a graph in hand, Dijkstra does the work:
from qgis.analysis import QgsGraphAnalyzer
start_vertex = graph.findVertex(tied[0])
tree, costs = QgsGraphAnalyzer.dijkstra(graph, start_vertex, 0)
end_vertex = graph.findVertex(tied[1])
print("cost to destination:", costs[end_vertex])
Breakdown: dijkstra returns two parallel lists indexed by vertex id. tree[v] is the id of the edge used to arrive at vertex v, or -1 where the vertex was never reached; costs[v] is the accumulated cost, and it is a very large number rather than infinity for unreachable vertices. The third argument selects which strategy to minimise, in the order they were added. Walking backwards through tree from the destination reconstructs the route, and reading costs against a budget gives you a service area — the same call answers both, which is why shortest paths and service areas sit next to each other.
Where the network comes from
Three sources cover most projects, and each brings its own preparation problem.
OpenStreetMap extracts are the usual starting point for road routing. They come pre-noded at junctions, which removes the biggest data problem before it starts, and they carry a oneway tag whose values are yes, no, -1 and a scattering of others. Those are strings, so they map directly onto the director's three values — with the important caveat that -1 means "one way against the digitised direction", which is the backward value and not a missing value.
National mapping agency road centrelines are usually topologically clean and carry a direction attribute under some local name and coding. They are also usually licensed, which matters if the output is published. Their weakness is that the direction coding is often numeric (1, 2, 3) and the director compares strings, so the values you pass must be "1", "2", "3" rather than integers.
Your own digitised network — a site's paths, a utility's pipes, a warehouse's aisles — is where the topology problems live, because it was drawn for a map rather than for a graph. Expect to split lines at intersections, expect a tolerance in the tens of centimetres, and expect to find a handful of genuinely disconnected fragments that need editing rather than a tolerance increase.
Whichever the source, the routing API cares only about geometry and one or two attributes, so the preparation is always the same shape: get it into a metric CRS, node it, and check what fraction of it is connected.
The Processing route
Everything above also exists as Processing algorithms — native:shortestpathpointtopoint, native:shortestpathpointtolayer, native:serviceareafrompoint and their siblings. They wrap the same classes with the same parameters, and for a one-off answer they are simply less code.
import processing
processing.run("native:shortestpathpointtopoint", {
"INPUT": roads,
"STRATEGY": 0,
"START_POINT": "445120,187330 [EPSG:27700]",
"END_POINT": "447980,189010 [EPSG:27700]",
"TOLERANCE": 0.5,
"OUTPUT": "TEMPORARY_OUTPUT",
})
Breakdown: STRATEGY is 0 for shortest and 1 for fastest; the fastest variant needs SPEED_FIELD or DEFAULT_SPEED to mean anything. Points are strings of x,y [authid], which is the Processing point-parameter convention rather than a geometry object. TOLERANCE is the same topology tolerance the builder takes, and it is worth setting explicitly rather than accepting the default on any real road layer.
Reconstructing the route geometry
The predecessor tree is a list of integers; turning it into a line takes a short backward walk.
from qgis.core import QgsGeometry
route = [graph.vertex(end_vertex).point()]
current = end_vertex
while current != start_vertex:
edge_id = tree[current]
if edge_id == -1:
raise SystemExit("destination is not reachable from the start")
current = graph.edge(edge_id).fromVertex()
route.insert(0, graph.vertex(current).point())
geometry = QgsGeometry.fromPolylineXY(route)
print(f"{geometry.length():.0f} m along {len(route) - 1} edges")
Breakdown: The loop walks from the destination back to the origin, inserting each vertex at the front so the finished list runs the right way. The -1 check is not optional — without it, an unreachable destination produces an infinite loop rather than an error, which is a genuinely unpleasant way to discover a disconnected network. fromVertex() and toVertex() are the current accessor names; QGIS versions before 3.24 called them inVertex() and outVertex(), and code that has to span both should branch on hasattr.
Note what this geometry is and is not. It is a polyline through graph vertices — that is, through the endpoints of the original line features — so it follows the network's shape only as closely as those endpoints describe it. Where the source lines are long curved segments the route cuts the corners, because the intermediate vertices of each source line are not in the graph at all. If you need the true drawn geometry, use the Processing algorithms, which reconstruct from the source features rather than from graph vertices.
Preparing a layer that will actually route
Most routing failures are data failures, and they show up as an unreachable destination rather than as an error. Three preparation steps cover almost all of them.
Split lines at every intersection so junctions become shared endpoints. Reproject to a metric CRS so tolerances and lengths are in metres rather than degrees — routing in EPSG:4326 makes every tolerance meaningless. Then check connectivity before you trust anything: run a service area from one point with a very large budget and see how much of the network it reaches. If it reaches a third of the roads, the network is in fragments.
Checking connectivity deserves a concrete recipe, because "does this network route" is the question you want answered before you build anything on top of it.
reachable = sum(1 for cost in costs if cost < 1e10)
print(f"{reachable} of {graph.vertexCount()} vertices reachable "
f"({100 * reachable / graph.vertexCount():.0f}%)")
Breakdown: Running Dijkstra once from a central point and counting what it reaches is the cheapest possible connectivity audit. On a properly noded network in a single connected component this prints close to 100%; anything below about 90% means the layer is in fragments, and the fragments are usually visible immediately if you write the unreachable vertices out as a point layer and look at where they cluster. The threshold of 1e10 is arbitrary but safe — the sentinel used for unreachable vertices is far larger than any real cost on a terrestrial network.
The other preparation worth doing once is a spatial subset. A graph over a whole country to answer questions inside one city wastes minutes of build time and gigabytes of memory for no benefit. Clip the network to the study area plus a generous buffer — generous meaning "wider than the longest route you will ask for", because a route clipped at the boundary will detour absurdly rather than leave the buffer. Clipping a vector layer covers the mechanics; the judgement about how much buffer is enough is yours.
Key takeaways
- A graph is built from line endpoints. Split your lines at junctions or nothing will connect.
- Set a topology tolerance appropriate to the data's precision, in a metric CRS, and never route in degrees.
- Direction comes from an attribute compared as an exact string; everything unmatched falls through to the default.
QgsNetworkSpeedStrategyneeds a unit multiplier, and getting it wrong scales every time by a constant.dijkstrareturns a predecessor tree and a cost array; routes and service areas are two readings of the same result.- Use the Processing algorithms for single answers and the graph API when many queries share one network.
- Turn restrictions are not modelled. If they matter, use a dedicated routing engine.
Frequently Asked Questions
Why does my route ignore a road that is clearly connected? Its endpoints do not coincide with the neighbouring road's endpoints within the tolerance, or the two roads cross without a shared vertex. Split the layer at intersections and raise the tolerance to match your data's digitising precision.
Can I route on a network stored in PostGIS?
Yes — the director takes any QgsVectorLayer, so a PostGIS layer works exactly like a file-based one, and reading it is covered in connecting to a PostGIS database. For very large networks, a database-side routing extension will outperform building a graph in memory.
How large a network can QGIS handle? The graph is held in memory with a vertex per endpoint and an edge per segment direction, so a few hundred thousand edges is comfortable and a few million is not. Clip the network to a generous buffer around your area of interest first.
Does the analysis use the ellipsoid for distances?QgsGraphBuilder takes an ellipsoid identifier and will compute geodesic lengths when given one, but the sane approach is to work in a projected CRS where planar lengths are already correct for the area.
Can I get a route that visits several stops in order? Run consecutive point-to-point queries against the same graph and concatenate. Optimising the order of the stops is the travelling salesman problem, which QGIS does not solve for you.
Why are all my costs enormous numbers?
Those are the unreachable vertices. Dijkstra fills them with a sentinel rather than infinity, so filter on reachability using tree[v] == -1 instead of comparing costs against a threshold.
Related
- Spatial Data Processing & Automation with PyQGIS — the section this guide belongs to
- Build a Network Graph with QgsGraphBuilder
- Find the Shortest Path Between Points in PyQGIS
- Calculate Service Areas in PyQGIS
- Add Speed and Travel Cost to a Network in PyQGIS
- Snap Points to a Network in PyQGIS
- Build an Origin-Destination Matrix in PyQGIS
- Geometry Operations & Predicates in PyQGIS