Check Line Network Connectivity in PyQGIS
A line layer can look perfectly connected on screen and be broken for every algorithm that walks it. A road that stops 30 cm short of a junction, a pipe that crosses another without a shared vertex, a river segment digitized in isolation — each splits the network into pieces, and a shortest-path or upstream trace simply stops at the break. These errors are invisible at normal zoom and fatal to analysis, which makes a connectivity check one of the most valuable things to run before any network analysis.
This recipe belongs to Data Quality & Topology Validation. It finds dangling line ends, separates genuine dead ends from undershoots, detects crossings without nodes, and counts the network's connected components.
Prerequisites
- QGIS 3.34 LTR or newer, or the QGIS 4 series.
- A line layer in a projected CRS, so tolerances are in metres.
- A tolerance that reflects the data's accuracy — typically 0.5 to 2 metres for roads digitized from imagery, a few centimetres for survey-grade utility data.
Find dangling ends
An end point that touches no other line is a dangle. Every network has some legitimately — cul-de-sacs, river sources, the stub of a service pipe — so the first step is simply to find them all.
from collections import Counter
from qgis.core import (QgsProject, QgsPointXY, QgsVectorLayer, QgsFeature,
QgsGeometry, QgsSpatialIndex)
roads = QgsProject.instance().mapLayersByName("roads")[0]
PREC = 3 # round coordinates to millimetres for matching
def key(pt):
return (round(pt.x(), PREC), round(pt.y(), PREC))
endpoint_count = Counter()
ends = []
for f in roads.getFeatures():
for part in f.geometry().constParts():
a, b = part.startPoint(), part.endPoint()
for p in (a, b):
endpoint_count[key(p)] += 1
ends.append((f.id(), QgsPointXY(p.x(), p.y())))
index = QgsSpatialIndex(roads.getFeatures(),
flags=QgsSpatialIndex.FlagStoreFeatureGeometries)
geoms = {f.id(): f.geometry() for f in roads.getFeatures()}
dangles = []
for fid, pt in ends:
if endpoint_count[key(pt)] > 1:
continue # shared with another end
g = QgsGeometry.fromPointXY(pt)
touching = [o for o in index.intersects(g.boundingBox().buffered(0.001))
if o != fid and geoms[o].distance(g) < 0.001]
if not touching:
dangles.append((fid, pt))
print(len(dangles), "dangling ends")
Breakdown: Counting rounded end coordinates finds ends shared with another line's end in one pass; rounding to millimetres absorbs floating-point noise without merging genuinely separate points. An end not shared with another end may still touch the middle of another line — a T-junction where the side road ends on the main road. The spatial index check catches those, so only ends that touch nothing at all count as dangles. Lines that are multipart are handled part by part, because each part has its own ends.
Tell undershoots from real dead ends
A dangle within a short distance of another line is probably an undershoot: the digitizer meant to connect it. A dangle with nothing nearby is probably a real dead end.
TOL = 2.0 # metres
dangle_layer = QgsVectorLayer(
f"Point?crs={roads.crs().authid()}&field=road_fid:integer"
"&field=gap_m:double&field=kind:string(12)", "dangles", "memory")
rows = []
dangle_count_per_line = Counter(fid for fid, _ in dangles)
for fid, pt in dangles:
g = QgsGeometry.fromPointXY(pt)
near = [o for o in index.nearestNeighbor(pt, 3, TOL * 5) if o != fid]
gap = min((geoms[o].distance(g) for o in near), default=float("inf"))
if dangle_count_per_line[fid] == 2 and geoms[fid].length() < TOL * 5:
kind = "stub"
elif gap <= TOL:
kind = "undershoot"
else:
kind = "dead_end"
f = QgsFeature(dangle_layer.fields())
f.setAttributes([fid, None if gap == float("inf") else round(gap, 3), kind])
f.setGeometry(g)
rows.append(f)
dangle_layer.dataProvider().addFeatures(rows)
QgsProject.instance().addMapLayer(dangle_layer)
print(Counter(r["kind"] for r in rows))
Breakdown: nearestNeighbor(point, k, maxDistance) returns candidate ids within a search radius, which keeps the search local on large networks. Recording the gap distance on each point lets you style undershoots by how far off they are and check the tolerance choice: a histogram of gaps for a well-digitized network shows a pile of tiny values (undershoots) and a sparse spread of larger ones (real dead ends), with a natural threshold between them. Short lines dangling at both ends are usually leftovers from splitting or deleting and are best reviewed separately.
Overshoots and missing nodes at crossings
Overshoots leave a short stub past a junction; crossings without a shared vertex mean two lines intersect but the network has no node there. Both are found by intersecting lines with each other.
crossings = []
for fid, geom in geoms.items():
for other in index.intersects(geom.boundingBox()):
if other <= fid:
continue
inter = geom.intersection(geoms[other])
if inter.isEmpty():
continue
for v in inter.vertices():
p = QgsPointXY(v.x(), v.y())
on_vertex_a = any(key(x) == key(p) for x in geom.vertices())
on_vertex_b = any(key(x) == key(p) for x in geoms[other].vertices())
if not (on_vertex_a and on_vertex_b):
crossings.append((fid, other, p))
print(len(crossings), "intersections without a shared vertex")
Breakdown: Two lines that should connect at a crossing must share a vertex there; if either line lacks a vertex at the intersection point, a graph builder will not create a node, and routes cannot turn. Not every unnoded crossing is an error — a bridge over a road should not connect to it — which is why networks often carry a level or z-order attribute. Filter the results by those attributes before fixing anything. An overshoot appears in this list as a crossing near a line's end; combine the two checks to label it. native:splitwithlines or native:lineintersections followed by splitting can insert the missing nodes in bulk.
Count connected components
The decisive question is whether the network is one piece. Building a graph from line end points and counting connected components answers it and shows where the pieces are.
parent = {}
def find(x):
parent.setdefault(x, x)
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for f in roads.getFeatures():
for part in f.geometry().constParts():
a, b = key(part.startPoint()), key(part.endPoint())
parent[find(a)] = find(b)
component_of_line = {}
for f in roads.getFeatures():
part = next(f.geometry().constParts())
component_of_line[f.id()] = find(key(part.startPoint()))
sizes = Counter(component_of_line.values())
main, main_size = sizes.most_common(1)[0]
print(f"{len(sizes)} components; main holds {main_size / len(component_of_line):.1%} of lines")
Breakdown: Treating each line as an edge between its end nodes and merging with union-find yields components in near-linear time. This uses end points only, so it reflects how a graph builder sees the network: T-junctions where a side road ends mid-line on the main road are not connected here, exactly as they would not be for a router unless the main road is split at that point. If that is how your data is modelled, split lines at their intersections first. Writing the component id back to each line and styling by it shows the isolated pieces immediately.
Fix undershoots and re-check
Undershoots within the tolerance can be closed by snapping line ends to the nearest line; Processing does this for a whole layer.
import processing
snapped = processing.run("native:snapgeometries", {
"INPUT": roads, "REFERENCE_LAYER": roads,
"TOLERANCE": TOL, "BEHAVIOR": 5, # 5 = move end points only, prefer closest point
"OUTPUT": "memory:roads_snapped"})["OUTPUT"]
QgsProject.instance().addMapLayer(snapped)
Breakdown: Snapping a layer to itself with behaviour 5 moves only line end points, each onto the closest point of a nearby line, which closes undershoots without disturbing the interior vertices of correctly drawn lines. The line that was snapped to still has no vertex at the junction, so split lines at their intersections afterwards if a graph builder needs nodes there. Use a tolerance no larger than the one used for classification, or real dead ends near other roads get pulled across. After snapping, rerun the dangle and component checks: the undershoot count should drop to near zero and the main component should grow. Snapping geometries to a layer explains each snapping behaviour.
QGIS version compatibility
All code runs on QGIS 3.34 LTR, 3.40 LTR and the QGIS 4 series. QgsSpatialIndex.nearestNeighbor with a maximum distance has existed since 3.8. native:snapgeometries behaviours are numbered the same across versions; check processing.algorithmHelp("native:snapgeometries") for the list on your release.
Troubleshooting
- Thousands of dangles in a clean network. Lines meet mid-segment (T-junctions) rather than at end points; the touch test handles this, but the component count will not until lines are split.
- Bridges are reported as missing nodes. Filter crossings by a level or z-order attribute.
- Snapping created odd geometry. The tolerance was larger than the spacing between parallel lines; reduce it.
- Coordinates do not match despite looking identical. Increase rounding precision only if data is survey-grade; otherwise millimetres is right.
Conclusion
Find dangling ends by counting rounded end points and testing for touches, classify them by distance into undershoots, dead ends and stubs, check crossings for shared vertices, count connected components with union-find, snap within the tolerance and re-check until the network is one main piece.
Frequently Asked Questions
Does QGIS's network analysis library fix these errors?
No. QgsGraphBuilder has a topology tolerance that merges nearby nodes, which hides small gaps but does not repair the data. See building a network graph.
Do rivers need the same check? Yes, plus direction: every segment should flow towards the outlet. Dangles at sources are expected.
Can I run this in PostGIS?
Yes, with pgRouting's pgr_analyzeGraph for dangles and pgr_connectedComponents for pieces.
What tolerance should I use for snapping? The smallest that closes your undershoots — read it off the gap distances recorded on the dangle layer.