Your Street Network Graph Is Disconnected or Missing Streets
Problem statement
The graph downloaded, it has a plausible number of nodes, and it will not route:
nx.shortest_path(G, origin, destination, weight="length")
networkx.exception.NetworkXNoPath: No path between 25497624 and 1521274653.
Or it routes, and the answers are absurd β a 200 m trip returning a 4 km route because the direct street is missing.
The graph is not broken in any way a length check would reveal. It is disconnected, and the standard connectivity test does not detect it:
print(nx.number_weakly_connected_components(G))
print(nx.number_strongly_connected_components(G))
1
28
One weakly connected component: the network is all one piece. Twenty-eight strongly connected components: following the direction of travel, it is twenty-eight islands.
Quick answer
Check strong connectivity, not weak, and route only inside the largest component:
import networkx as nx
components = list(nx.strongly_connected_components(G))
largest = max(components, key=len)
stranded = len(G.nodes) - len(largest)
print(f"{len(components)} strongly connected components")
print(f"{stranded} nodes outside the largest ({stranded / len(G.nodes):.1%})")
G = G.subgraph(largest).copy() # .copy() β subgraph returns a read-only view
28 strongly connected components
33 nodes outside the largest (0.7%)
| Symptom | Cause | Fix |
|---|---|---|
NetworkXNoPath on nearby nodes |
one endpoint outside the largest SCC | restrict to the largest SCC |
| routes far longer than expected | the direct street was severed at the boundary | download a buffer |
| whole areas missing | wrong network_type for the mode |
walk for pedestrians |
| a pedestrian route fails in a city centre | pedestrianised streets absent from drive |
use the walk network |
| the graph is smaller than expected | Overpass truncated the download | ox.settings.log_console = True |
KeyError: 'length' |
it is a multigraph | G[u][v][0]["length"] |
Step-by-step solution
1. Measure how bad it is before fixing anything
def connectivity_report(G):
weak = nx.number_weakly_connected_components(G)
components = sorted(nx.strongly_connected_components(G), key=len, reverse=True)
largest = components[0]
stranded = len(G.nodes) - len(largest)
print(f" {len(G.nodes):,} nodes, {len(G.edges):,} edges")
print(f" weakly connected components: {weak}")
print(f" strongly connected components: {len(components)}")
print(f" largest: {len(largest):,} nodes; stranded: {stranded} "
f"({stranded / len(G.nodes):.2%})")
print(f" component sizes: {[len(c) for c in components[:6]]}")
return largest
largest = connectivity_report(G)
4,935 nodes, 11,151 edges
weakly connected components: 1
strongly connected components: 28
largest: 4,902 nodes; stranded: 33 (0.67%)
component sizes: [4902, 3, 2, 2, 1, 1]
That size distribution is the diagnosis. One huge component and a scatter of ones and twos means one-way traps β individual nodes you can enter and not leave. Several large components would mean something else: a river with no bridges in the extract, or a genuinely severed network.
2. Understand how little it takes to break routing
Thirty-three nodes out of 4,935 sounds negligible. It is not:
import numpy as np
rng = np.random.default_rng(3)
stranded = [n for n in G.nodes if n not in largest]
targets = rng.choice(list(largest), 10)
failures = sum(1 for a in stranded[:20] for b in targets if not nx.has_path(G, a, b))
print(f"{failures} of 200 routes from stranded nodes fail ({failures / 200:.0%})")
120 of 200 routes from stranded nodes fail (60%)
Sixty percent. If a geocoded address snaps to one of those nodes, most routes from it raise. And because the nodes are scattered, the failures look random rather than structural.
3. Restrict to the largest component
G = G.subgraph(largest).copy()
assert nx.is_strongly_connected(G)
print(f"routable: {len(G.nodes):,} nodes, guaranteed no NetworkXNoPath")
routable: 4,902 nodes, guaranteed no NetworkXNoPath
That assertion is the whole point. Inside a strongly connected graph, every pair of nodes has a path by definition, so NetworkXNoPath becomes impossible for structural reasons.
.copy() is required β subgraph() returns a read-only view, and ox.add_edge_speeds on a view fails.
4. Reduce the artefacts with a buffer
Many stranded nodes are created by the download boundary rather than by the real street network:
for buffer in (0, 500, 1500):
g = ox.graph_from_point(centre, dist=2000 + buffer, network_type="drive",
truncate_by_edge=True)
comps = list(nx.strongly_connected_components(g))
largest = max(comps, key=len)
print(f"buffer {buffer:5} m: {len(g.nodes):6,} nodes, {len(comps):3} components, "
f"{len(g.nodes) - len(largest):3} stranded")
A street that leaves the extract and comes back is severed, so any node whose only exit was that street becomes a trap. Downloading a buffer and analysing the inner area pushes those artefacts outside the region you care about.
truncate_by_edge=True helps independently: it keeps edges crossing the boundary whole rather than cutting them mid-way.
5. Check you have the right network at all
for mode in ("drive", "walk", "bike"):
g = ox.graph_from_point(centre, dist=2500, network_type=mode)
print(f"{mode:6}: {len(g.nodes):6,} nodes, "
f"{nx.number_strongly_connected_components(g):3} components")
drive : 4,935 nodes, 28 components
walk : 25,919 nodes, 1 components
bike : 14,337 nodes, 83 components
If you are routing pedestrians on the drive network, most of the "missing streets" are footpaths, alleys and pedestrianised areas that were never downloaded. The walk network has five times the nodes and, because walking ignores one-way restrictions, exactly one strongly connected component.
The bike network is the worst of the three for connectivity β 83 components β because cycle infrastructure is full of contraflow lanes and one-way links. Restricting to the largest component matters more there, not less.
Code examples
Example 1 β a diagnostic that names the cause
import networkx as nx
import numpy as np
import osmnx as ox
def diagnose_graph(G, *, expect_type=None):
problems = []
if not G.is_directed():
problems.append("graph is undirected β one-way streets are not represented")
return problems
components = sorted(nx.strongly_connected_components(G), key=len, reverse=True)
largest = components[0]
stranded = len(G.nodes) - len(largest)
sizes = [len(c) for c in components]
if len(components) > 1:
tiny = sum(1 for s in sizes[1:] if s <= 3)
big_others = [s for s in sizes[1:] if s > 20]
problems.append(
f"{len(components)} strongly connected components; "
f"{stranded} nodes ({stranded / len(G.nodes):.2%}) outside the largest"
)
if tiny == len(sizes) - 1:
problems.append(f"all {tiny} extra components have <= 3 nodes β "
f"one-way traps and boundary artefacts")
if big_others:
problems.append(f"components of {big_others} nodes β a genuine severance "
f"(a river, a railway, or a truncated download)")
lengths = np.array([d.get("length", np.nan) for *_, d in G.edges(data=True)])
if np.isnan(lengths).any():
problems.append(f"{np.isnan(lengths).sum()} edges have no length attribute")
isolated = [n for n in G.nodes if G.out_degree(n) == 0]
sinks = [n for n in G.nodes if G.in_degree(n) == 0]
if isolated:
problems.append(f"{len(isolated)} nodes have no outgoing edge β "
f"you can arrive and not leave")
if sinks:
problems.append(f"{len(sinks)} nodes have no incoming edge β "
f"you can leave and not arrive")
graph_type = G.graph.get("network_type", "unknown")
if expect_type and graph_type not in (expect_type, "unknown"):
problems.append(f"network_type is {graph_type!r}, expected {expect_type!r}")
print(f" {len(G.nodes):,} nodes, {len(G.edges):,} edges, "
f"{len(components)} strong component(s)")
print(f" component sizes: {sizes[:8]}{' β¦' if len(sizes) > 8 else ''}")
for problem in problems:
print(f" β {problem}")
if not problems:
print(" β strongly connected and routable")
return problems
diagnose_graph(G, expect_type="drive")
4,935 nodes, 11,151 edges, 28 strong component(s)
component sizes: [4902, 3, 2, 2, 1, 1, 1, 1] β¦
β 28 strongly connected components; 33 nodes (0.67%) outside the largest
β all 27 extra components have <= 3 nodes β one-way traps and boundary artefacts
β 4 nodes have no outgoing edge β you can arrive and not leave
The distinction between "all tiny" and "some large" is what tells you whether to buffer the download or to go looking for a missing bridge.
Example 2 β proving where the stranded nodes are
import geopandas as gpd
def map_stranded(G, path=None):
largest = max(nx.strongly_connected_components(G), key=len)
nodes, _ = ox.graph_to_gdfs(G)
nodes["stranded"] = ~nodes.index.isin(largest)
nodes["in_degree"] = [G.in_degree(n) for n in nodes.index]
nodes["out_degree"] = [G.out_degree(n) for n in nodes.index]
stranded = nodes[nodes["stranded"]].copy()
centre = nodes.geometry.union_all().centroid
stranded["dist_from_centre_deg"] = stranded.distance(centre)
edge_share = (stranded["dist_from_centre_deg"]
> nodes.distance(centre).quantile(0.9)).mean()
print(f" {len(stranded)} stranded nodes")
print(f" {edge_share:.0%} of them are in the outer 10% of the extract")
print(stranded[["in_degree", "out_degree"]].value_counts().head(4).to_string())
if path:
stranded.to_file(path, driver="GPKG")
return stranded
stranded = map_stranded(G)
33 stranded nodes
76% of them are in the outer 10% of the extract
in_degree out_degree
1 0 12
0 1 9
1 1 7
2 1 3
Seventy-six percent in the outer tenth of the extract β these are boundary artefacts, not real features of the street network. And the degree table shows what they are: twelve nodes with an entrance and no exit, nine with an exit and no entrance.
Writing them to a GeoPackage and opening them over a basemap is the fastest way to confirm it. They will sit on the rim.
Example 3 β a safe routing wrapper
class GraphNotRoutable(RuntimeError):
pass
def prepare_for_routing(G, *, min_share=0.95):
"""Return a strongly connected subgraph, or explain why that is not sensible."""
components = sorted(nx.strongly_connected_components(G), key=len, reverse=True)
largest = components[0]
share = len(largest) / len(G.nodes)
if share < min_share:
sizes = [len(c) for c in components[:5]]
raise GraphNotRoutable(
f"the largest strongly connected component holds only {share:.1%} of nodes "
f"(sizes {sizes}). This is a severed network, not one-way traps β "
f"check network_type, the download extent, and whether a bridge is missing."
)
G = G.subgraph(largest).copy()
print(f" routable: {len(G.nodes):,} of {len(G.nodes) + (len(largest) and 0):,} "
f"nodes ({share:.2%} kept)")
assert nx.is_strongly_connected(G)
return G
def route(G, origin_xy, destination_xy, weight="length"):
origin = ox.nearest_nodes(G, origin_xy[0], origin_xy[1])
destination = ox.nearest_nodes(G, destination_xy[0], destination_xy[1])
if origin == destination:
return [origin], 0.0
path = nx.shortest_path(G, origin, destination, weight=weight)
cost = nx.shortest_path_length(G, origin, destination, weight=weight)
return path, cost
G = prepare_for_routing(G)
path, metres = route(G, (-2.2426, 53.4808), (-2.2300, 53.4880))
print(f" {len(path)} nodes, {metres:,.0f} m")
routable: 4,902 of 4,902 nodes (99.33% kept)
87 nodes, 5,479 m
The min_share guard is what separates the two failure modes. Losing 0.7% of nodes to one-way traps is normal and worth doing silently. Losing 40% means something is structurally wrong, and quietly dropping it would hide a real problem β a missing bridge, or the wrong network type β behind a plausible-looking result.
Explanation
Why weak connectivity is the wrong test
nx.is_connected does not exist for directed graphs, so the natural substitute is nx.is_weakly_connected β which ignores edge direction entirely.
For a street network that is almost always True, because the underlying streets do form one connected shape. It tells you nothing about whether you can drive anywhere, which is the question routing asks.
The measured gap on a 2.5 km city-centre extract: 1 weak component, 28 strong ones. Any check that stops at weak connectivity will pass a graph that fails 60% of routes from its stranded nodes.
Why 0.7% of nodes cause so much trouble
Stranded nodes are not randomly distributed among your queries. They are disproportionately at the edge of the extract, and 76% of them here are in the outer tenth.
That matters because origins and destinations in a real analysis are often near the edge β a study area is rarely centred on its own bounding box. And a geocoded address snaps to whichever node is nearest, with no awareness of whether that node can be left.
The has_path check is cheap and answers the question directly, but restricting the graph up front is better: it makes the failure impossible rather than catching it.
Why the boundary creates them
Consider a one-way system where the outbound carriageway is inside your extract and the return runs 50 m outside it. In reality you drive out and come back. In the extract the return does not exist, so every node on the outbound leg can be entered and never left.
The download boundary is arbitrary, so these artefacts are arbitrary too β they move when you change dist. That is the tell: if the stranded set changes completely between a 2 km and a 3 km download, they are boundary artefacts, not features of the city.
The fix is a buffer wide enough to contain the detours, plus truncate_by_edge=True so boundary-crossing edges survive whole.
Why "missing streets" is usually the wrong network type
A drive network excludes footpaths, alleys, steps and pedestrianised streets. Over a city centre that is a large fraction of the walkable network, and the symptom is exactly "streets I can see on the map are not in my graph".
drive : 4,935 nodes
walk : 25,919 nodes
Five times as many. If the streets you expected are pedestrian, no amount of connectivity work will find them β you downloaded a different network.
The same applies in reverse: routing a car on a walk network produces routes through pedestrian precincts and the wrong way up one-way streets, because the walk network has no one-way edges at all.
Edge cases or notes
nx.is_weakly_connectedis nearly alwaysTruefor a street network and tells you nothing useful.subgraph()returns a read-only view..copy()before adding speeds or travel times.- Component size distribution is the diagnosis β many tiny ones mean one-way traps, a few large ones mean a real severance.
truncate_by_edge=Truekeeps boundary-crossing edges whole and reduces the artefacts.- The walk network has one strongly connected component because pedestrians ignore one-way restrictions.
- A missing bridge or tunnel produces two large components rather than a tail of small ones.
has_pathis cheap and worth calling before a shortest-path in any loop over many pairs.- Truncated downloads look like missing streets. Turn on
ox.settings.log_consoleto see the Overpassremark.
Internal links
- Street networks as graphs explained β weak versus strong connectivity
- How to build a street network graph in Python with OSMnx β buffering and truncation
- NetworkX says no path exists between two nodes β the exception this prevents
- Street network routing returns absurd distances β the other routing failure
- OSMnx download fails, hangs or times out β the truncated-download cause
- How to snap points to a street network in Python β snapping onto a stranded node
- How to clean line network dangles in Python β the vector equivalent
- How to download OpenStreetMap data in Python with OSMnx β checking what actually arrived
FAQ
Why does my graph fail to route when it is connected?
You checked weak connectivity, which ignores direction. Routing needs strong connectivity, and a street network can be weakly connected while containing dozens of strong components.
What are stranded nodes?
Nodes outside the largest strongly connected component β places you can enter and not leave, or the reverse. Usually one-way traps or artefacts of the download boundary.
Is losing 0.7% of nodes acceptable?
Yes, and it is normal. Losing 40% is not β that indicates a severed network, a missing bridge, or the wrong network_type.
Why are streets missing from my graph?
Almost always the wrong network_type. A drive network excludes footpaths and pedestrianised streets, which is most of a city centre's walkable network.
How do I stop NetworkXNoPath entirely?
Restrict the graph to its largest strongly connected component. Inside one, every pair of nodes has a path by definition.
Why does .copy() matter after subgraph()?
subgraph() returns a read-only view. Adding edge speeds or travel times to it raises.
Do stranded nodes move when I change the download size?
Yes, if they are boundary artefacts β which most are. That instability is how you tell them from genuine features of the street network.