Calculate Service Areas in PyQGIS

"Everywhere within ten minutes' drive of this depot" is a network question, and a buffer is not an answer to it. A ten-kilometre circle drawn round a depot includes the far side of an estuary and excludes the town twelve kilometres along the motorway. A service area follows the roads, and in PyQGIS it costs one Dijkstra run — the same run that produces a shortest path, read against a budget instead of towards a destination.

This recipe belongs to Network Analysis & Routing in PyQGIS. It covers the Processing algorithm, the graph API version with several bands at once, and the step everyone underestimates: turning a set of reachable edges into a polygon somebody can put on a map.

A buffer answers a different questionA ten kilometre circle around a depot includes an area across an estuary that no road reaches, and stops short of a town that a fast road serves comfortably within the time budget. The network service area follows the roads, so it excludes the first and includes the second.Same depot, same ten minutes, different answers10 km bufferthe estuary is inside the circle10 minute service areareaches the far town, stops at the waterthe difference is largest exactly where the decision matters

Prerequisites

The Processing algorithm

import processing
from qgis.core import QgsProject

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

result = processing.run("native:serviceareafrompoint", {
    "INPUT": roads,
    "STRATEGY": 1,
    "START_POINT": "445120,187330 [EPSG:27700]",
    "TRAVEL_COST2": 600,
    "SPEED_FIELD": "speed_kph",
    "DEFAULT_SPEED": 48,
    "TOLERANCE": 0.5,
    "INCLUDE_BOUNDS": True,
    "OUTPUT_LINES": "TEMPORARY_OUTPUT",
    "OUTPUT": "TEMPORARY_OUTPUT",
})

Breakdown: STRATEGY 1 is time, so TRAVEL_COST2 is in seconds — 600 for ten minutes. With strategy 0 the same parameter is a distance in layer units, and mixing them up gives you a catchment that is either the whole county or one junction. The parameter is named TRAVEL_COST2 because the original TRAVEL_COST was replaced when the units were clarified; passing the old name still works on many builds and is deprecated. INCLUDE_BOUNDS adds the partially traversed edges at the frontier, which is what makes a subsequent polygon look sane rather than ragged. Two outputs come back: OUTPUT is the reachable nodes as points, and OUTPUT_LINES is the reachable network as lines.

Several bands from one run

The graph API's advantage here is not subtlety, it is that five bands cost the same as one.

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

speed_index = roads.fields().indexOf("speed_kph")

director = QgsVectorLayerDirector(
    roads, -1, "", "", "", QgsVectorLayerDirector.DirectionBoth
)
director.addStrategy(QgsNetworkSpeedStrategy(speed_index, 48.0, 1000.0 / 3600.0))

depot = QgsPointXY(445120, 187330)
builder = QgsGraphBuilder(roads.sourceCrs(), True, 0.5)
tied = director.makeGraph(builder, [depot])
graph = builder.graph()

origin = graph.findVertex(tied[0])
tree, costs = QgsGraphAnalyzer.dijkstra(graph, origin, 0)

bands = {}
for minutes in (5, 10, 15, 20, 30):
    budget = minutes * 60
    bands[minutes] = [
        vertex_id for vertex_id, cost in enumerate(costs)
        if tree[vertex_id] != -1 and cost <= budget
    ]
    print(f"{minutes:2d} min: {len(bands[minutes])} vertices")

Breakdown: One Dijkstra run fills costs for every vertex in the network, so every band is a filter over the same array — the thirty-minute band costs no more to compute than the five-minute one. Testing tree[vertex_id] != -1 before the cost comparison is what excludes unreachable vertices, whose sentinel cost is large but finite and would otherwise sail past a generous budget. The bands nest by construction, so the ten-minute list contains the five-minute one, which is exactly what you want when turning them into rings.

From vertices to a polygon

The polygon is a presentation choice, not a resultA convex hull around the reachable vertices includes large areas no road reaches. A concave hull follows the outline of the reached network much more closely. Buffering the reachable edges themselves produces a shape that hugs the roads, which is honest but often reads as a spider rather than a catchment.Three shapes, all from the same reachable setconvex hullclaims land no road reachesconcave hullusually the right compromisebuffered edgeshonest, reads as a spidersay which one you used — the area figure differs by a factor of two between them

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

areas = QgsVectorLayer(
    "Polygon?crs=" + roads.sourceCrs().authid(), "service_areas", "memory"
)
areas.dataProvider().addAttributes([QgsField("minutes", QVariant.Int)])
areas.updateFields()

features = []
for minutes, vertex_ids in bands.items():
    points = [graph.vertex(v).point() for v in vertex_ids]
    multipoint = QgsGeometry.fromMultiPointXY(points)
    hull = multipoint.concaveHull(0.3, False)
    feature = QgsFeature(areas.fields())
    feature.setGeometry(hull)
    feature["minutes"] = minutes
    features.append(feature)

areas.dataProvider().addFeatures(features)
areas.updateExtents()

Breakdown: concaveHull(alpha, allowHoles) takes a threshold between 0 and 1 where lower values hug the points more tightly; 0.3 is a reasonable starting point for a road catchment and worth tuning by eye. Setting allowHoles to False keeps the result as a solid catchment rather than one perforated wherever a park has no roads — which is a presentational choice, and the opposite choice is equally defensible if the holes are meaningful. Because the bands nest, drawing the largest first and the smallest last gives a ring map for free without any clipping. concaveHull needs QGIS 3.16 or newer; on older builds convexHull() is the fallback and over-claims noticeably.

Choosing what "reached" means

The single biggest source of disagreement between two service areas of the same place is not the algorithm, it is the definition. Three choices each change the answer by more than any parameter:

Nodes or edges. Taking the reachable vertices means a road half-traversed at the budget contributes nothing, so the catchment shrinks by up to one edge length in every direction. Taking the reachable edges with INCLUDE_BOUNDS extends to the frontier. On a rural network with kilometre-long edges this is a large difference.

Speed assumptions. A uniform 48 km/h and a per-class speed table produce catchments that differ by a third in a mixed urban-rural area. Whichever you use, record it next to the output.

Direction. A catchment computed outward from a depot answers "where can I deliver to". Computed inward — running Dijkstra on a graph whose direction values are swapped — it answers "who can reach me", and on a one-way network these are genuinely different shapes. The Processing algorithms have no inward mode, so this one needs the graph API.

Computing the inward catchment

"Who can reach me" is the question a hospital, a school or a depot receiving deliveries actually asks, and on a network with one-way streets it is not the same set as "where can I reach". The graph API answers it by reversing the direction interpretation.

inward_director = QgsVectorLayerDirector(
    roads,
    roads.fields().indexOf("oneway"),
    "T", "F", "B",                      # forward and backward values swapped
    QgsVectorLayerDirector.DirectionBoth,
)
inward_director.addStrategy(QgsNetworkSpeedStrategy(speed_index, 48.0, 1000.0 / 3600.0))

inward_builder = QgsGraphBuilder(roads.sourceCrs(), True, 0.5)
inward_tied = inward_director.makeGraph(inward_builder, [depot])
inward_graph = inward_builder.graph()

inward_tree, inward_costs = QgsGraphAnalyzer.dijkstra(
    inward_graph, inward_graph.findVertex(inward_tied[0]), 0
)

Breakdown: Swapping the forward and backward strings reverses every one-way edge, so a Dijkstra run outward from the depot on the reversed graph enumerates exactly the vertices from which the depot is reachable in the original. It is a second graph and a second build, which is the price of the answer. On a network with no one-way streets the two catchments are identical and the whole exercise is unnecessary — which is worth checking first, since a oneway field where every value is null makes this a pure waste of a build.

The two catchments diverge most in exactly the places that matter: city centres with one-way systems, and anywhere a dual carriageway separates travel directions. Reporting a delivery catchment when the question was about patients arriving is a mistake nobody spots on the map, because both shapes look plausible.

QGIS version compatibility

native:serviceareafrompoint and native:serviceareafromlayer have been present since 3.0. The TRAVEL_COST2 parameter replaced TRAVEL_COST in 3.10 to make the time-versus-distance units explicit; the old name is still accepted and still ambiguous, so use the new one. QgsGeometry.concaveHull() requires 3.16.

Troubleshooting

  • The service area covers the entire network. Strategy 0 with a budget you intended as seconds, or a speed multiplier out by a factor of 1000.
  • The service area is a single point. Strategy 1 with a budget you intended as metres.
  • The polygon has spikes reaching far outside the roads. Convex hull, or a concave hull threshold set too high.
  • A neighbourhood across a river is included. The network has a spurious connection there, or the tolerance welded two roads that do not meet.
  • Nothing is reached beyond the immediate junctions. The direction field's default is forward rather than both, so every unmarked street became one-way.
  • The area figure changed after a QGIS upgrade. Check whether INCLUDE_BOUNDS defaults differently, and whether the deprecated TRAVEL_COST is being used.

Conclusion

Run Dijkstra once, filter the cost array for every band you need, and turn the reachable set into polygons with a concave hull. The computation is the easy half; the half that decides whether anyone believes the map is stating plainly what "within ten minutes" meant — which speeds, which direction, and whether the frontier edges counted.

Frequently Asked Questions

Can I compute service areas for many origins at once?native:serviceareafromlayer takes a point layer and runs one catchment per feature. With the graph API, build once and loop the Dijkstra call over origins, which is much faster than the algorithm's per-feature rebuild.

How do I make the bands into rings rather than nested polygons? Subtract each band from the next with difference(), working from the smallest outwards. Nested polygons drawn largest-first look identical and are simpler; rings are what you want if the areas will be measured.

Why is my ten-minute area smaller than a ten-kilometre buffer everywhere? Because roads are not straight. A detour ratio of 1.3 is typical, so a ten-minute drive at 48 km/h covers about eight kilometres of ground in a straight line, not eight kilometres of road in every direction.

Can I weight the catchment by population? Not directly, but the polygon is an ordinary layer, so zonal statistics against a population raster, or a spatial join against census polygons, gives the served population immediately.