Street Networks as Graphs Explained
Problem statement
A street network looks like a set of lines. Load it as one and every routing question becomes impossible:
streets = gpd.read_file("streets.gpkg")
print(len(streets), "lines")
11151 lines
Eleven thousand LineStrings, and nothing that answers "how do I get from A to B". A GeoDataFrame of lines has no notion of connection: two roads meeting at a junction are two unrelated rows, and there is nothing to traverse.
A graph adds the missing structure. Nodes are junctions, edges are the road segments between them, and the connections are explicit. Once you have that, shortest paths, isochrones and accessibility are all standard algorithms.
The conversion is not free, and the things that go wrong are all consequences of one fact: a street network is a directed multigraph, and every word in that phrase matters.
Quick answer
import networkx as nx
import osmnx as ox
G = ox.graph_from_point((53.4808, -2.2426), dist=2500, network_type="drive")
print(f"{len(G.nodes):,} nodes, {len(G.edges):,} edges")
print(f"directed: {G.is_directed()}, multigraph: {G.is_multigraph()}")
print(f"weakly connected components: {nx.number_weakly_connected_components(G)}")
print(f"strongly connected components: {nx.number_strongly_connected_components(G)}")
4,935 nodes, 11,151 edges
directed: True, multigraph: True
weakly connected components: 1
strongly connected components: 28
One weakly connected component β the network is all one piece if you ignore direction. Twenty-eight strongly connected components once you respect one-way streets: 33 nodes you can drive into and not out of, or the reverse.
| Term | Meaning | Consequence |
|---|---|---|
| node | a junction, or the end of a street | routing endpoints |
| edge | a road segment between two nodes | carries length, oneway, maxspeed |
| directed | edges have a direction | AβB may exist while BβA does not |
| multigraph | two nodes can have several edges | a dual carriageway, a slip road |
| weakly connected | connected ignoring direction | the network is one piece |
| strongly connected | every node reaches every other | what routing actually needs |
Step-by-step solution
1. Understand what becomes a node
A node is created where streets meet, and where a street ends. Not at every vertex β a curving road with 200 vertices between two junctions is one edge whose geometry holds all 200 points.
lengths = [d["length"] for *_, d in G.edges(data=True)]
print(f"edges: {len(lengths):,}, median length {np.median(lengths):.0f} m, "
f"longest {max(lengths):.0f} m")
edges: 11,151, median length 51 m, longest 1065 m
A median of 51 m is the spacing of junctions in a city centre. The 1,065 m edge is a road with no intersections along it β a bypass or a rural stretch.
This is what "simplified" means in OSMnx: interstitial vertices are folded into edge geometry rather than becoming degree-2 nodes. It reduces the graph by roughly an order of magnitude and changes no distances.
2. Take the direction seriously
oneway = sum(1 for *_, d in G.edges(data=True) if d.get("oneway"))
print(f"{oneway:,} of {len(G.edges):,} edges are one-way ({oneway / len(G.edges):.1%})")
1,321 of 11,151 edges are one-way (11.8%)
Twelve percent. OSMnx represents a two-way street as two directed edges, one each way, so the graph is directed throughout β one-way streets simply have one of the pair missing.
That is why the walking network behaves so differently:
W = ox.graph_from_point((53.4808, -2.2426), dist=2500, network_type="walk")
print(f"walk: {len(W.nodes):,} nodes, "
f"{nx.number_strongly_connected_components(W)} strongly connected component(s)")
walk: 25,919 nodes, 1 strongly connected component(s)
One component, because pedestrians ignore one-way restrictions. Five times as many nodes, because footpaths, alleys and crossings are included.
3. Know the difference between weak and strong connectivity
This distinction causes more routing failures than anything else:
- Weakly connected β every node is reachable from every other if you ignore edge direction.
- Strongly connected β every node is reachable from every other following the direction of travel.
largest = max(nx.strongly_connected_components(G), key=len)
outside = len(G.nodes) - len(largest)
print(f"largest strongly connected component: {len(largest):,} nodes")
print(f"outside it: {outside} nodes ({outside / len(G.nodes):.1%})")
largest strongly connected component: 4,902 nodes
outside it: 33 nodes (0.7%)
Only 0.7% of nodes β and they cause failures wildly out of proportion:
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 random routes from stranded nodes fail")
120 of 200 random routes from stranded nodes fail
Sixty percent. A node in a cul-de-sac reached only by a one-way street can be driven to and not from. If your geocoded address lands on one, every route out of it raises.
4. Handle the multigraph
Two nodes can be joined by more than one edge β a dual carriageway, a road plus a parallel service road, a junction with a slip road:
for u, v, k in G.edges(keys=True):
if k > 0:
print(f"node {u} -> node {v} has a second edge (key {k})")
break
Every edge is identified by (u, v, key), not (u, v). Code that does G[u][v]["length"] works on a simple graph and returns a dict of keys on a multigraph:
print(G[u][v]) # {0: {...}, 1: {...}}
print(G[u][v][0]["length"]) # the first parallel edge
Forgetting the key is a common source of KeyError: 'length' on an object that clearly has lengths.
5. Remember it is in EPSG:4326
print(G.graph)
{'created_date': '2026-08-26 12:30:02', 'created_with': 'OSMnx 2.1.1',
'crs': 'epsg:4326', 'simplified': True}
Node coordinates are degrees. Edge length values are metres, computed by OSMnx on the sphere β so routing on length is correct, while any Euclidean distance you compute yourself from node x/y is in degrees.
ox.project_graph(G) reprojects to a suitable UTM zone when you need planar geometry.
Code examples
Example 1 β a graph audit before you route on it
import networkx as nx
import numpy as np
import osmnx as ox
def audit_graph(G):
directed = G.is_directed()
report = {
"nodes": len(G.nodes),
"edges": len(G.edges),
"directed": directed,
"multigraph": G.is_multigraph(),
"crs": G.graph.get("crs"),
"simplified": G.graph.get("simplified"),
}
if directed:
weak = list(nx.weakly_connected_components(G))
strong = list(nx.strongly_connected_components(G))
largest = max(strong, key=len)
report.update({
"weak_components": len(weak),
"strong_components": len(strong),
"largest_strong": len(largest),
"stranded_nodes": len(G.nodes) - len(largest),
"stranded_share": (len(G.nodes) - len(largest)) / len(G.nodes),
})
lengths = np.array([d["length"] for *_, d in G.edges(data=True)])
report["median_edge_m"] = float(np.median(lengths))
report["oneway_share"] = sum(1 for *_, d in G.edges(data=True)
if d.get("oneway")) / len(G.edges)
print(f" {report['nodes']:,} nodes, {report['edges']:,} edges "
f"({'directed' if directed else 'undirected'} "
f"{'multi' if report['multigraph'] else 'simple'}graph, {report['crs']})")
print(f" median edge {report['median_edge_m']:.0f} m, "
f"{report['oneway_share']:.1%} one-way")
if directed:
print(f" {report['weak_components']} weak / {report['strong_components']} strong "
f"components; {report['stranded_nodes']} nodes "
f"({report['stranded_share']:.1%}) outside the largest")
if report["stranded_share"] > 0:
print(f" -> route only within the largest strongly connected component")
return report
G = ox.graph_from_point((53.4808, -2.2426), dist=2500, network_type="drive")
audit_graph(G)
4,935 nodes, 11,151 edges (directed multigraph, epsg:4326)
median edge 51 m, 11.8% one-way
1 weak / 28 strong components; 33 nodes (0.7%) outside the largest
-> route only within the largest strongly connected component
That last line is the actionable one. Restricting to the largest strongly connected component is the single most effective thing you can do to make routing reliable β see no path exists between two nodes.
Example 2 β the graph as two GeoDataFrames
nodes, edges = ox.graph_to_gdfs(G)
print(f"nodes: {nodes.shape}, index {nodes.index.names}")
print(f"edges: {edges.shape}, index {edges.index.names}")
print(edges[["highway", "length", "oneway"]].head(3).to_string())
nodes: (4935, 7), index ['osmid']
edges: (11151, 15), index ['u', 'v', 'key']
highway length oneway
u v key
234984 7560500101 0 trunk_link 187.850945 True
3449204 0 primary 59.613041 False
25423456 0 primary 64.232902 False
Node 234984 has three outgoing edges: a one-way slip road and two ordinary primary-road segments. Each two-way segment also appears with u and v swapped further down the frame β identical geometry and length, opposite direction. That is what "directed" means in practice, and it is why the edge count is roughly double the number of street segments you would count on a map.
The (u, v, key) MultiIndex is the edge identity. Round-tripping back to a graph is exact:
G2 = ox.graph_from_gdfs(nodes, edges)
print(len(G2.nodes) == len(G.nodes), len(G2.edges) == len(G.edges))
True True
Example 3 β what "simplified" actually removes
G_raw = ox.graph_from_point((53.4808, -2.2426), dist=2500,
network_type="drive", simplify=False)
G_simple = ox.simplify_graph(G_raw)
for label, graph in [("raw", G_raw), ("simplified", G_simple)]:
lengths = [d["length"] for *_, d in graph.edges(data=True)]
degrees = dict(graph.degree())
deg2 = sum(1 for d in degrees.values() if d == 2)
print(f" {label:11} {len(graph.nodes):6,} nodes ({deg2:5,} of degree 2), "
f"{len(graph.edges):6,} edges, median {np.median(lengths):5.1f} m, "
f"total {sum(lengths) / 1000:7.1f} km")
raw 25,191 nodes (6,196 of degree 2), 46,845 edges, median 8.5 m, total 729.5 km
simplified 4,978 nodes (1,408 of degree 2), 11,207 edges, median 51.1 m, total 729.5 km
The total length is identical β 729.5 km either way. Simplification removes about 20,000 nodes, most of them mid-street vertices, and folds their geometry into the surviving edges. The network is exactly the same shape and the graph is a fifth the size.
(A few degree-2 nodes survive simplification. They are places where two streets meet end-to-end with different attributes β a change of name, speed limit or surface β which OSMnx keeps because the edges cannot be merged without losing information.)
Always route on the simplified graph. The raw one is five times larger for identical answers, and its extra nodes are not places anybody can turn.
Explanation
Why the graph is directed even where streets are two-way
A directed graph can express both a two-way street (two edges) and a one-way street (one edge). An undirected graph can express only the first.
Since 11.8% of edges here are one-way, an undirected representation would be wrong for one segment in eight. OSMnx therefore makes everything directed and duplicates two-way streets, which costs memory and gets the model right.
The consequence is that G[u][v] and G[v][u] are different edges, and removing one does not remove the other.
Why strong connectivity is the one that matters
Weak connectivity asks "is this network one piece". Strong connectivity asks "can I get from anywhere to anywhere". For routing, only the second question matters.
The measured gap here is stark: one weak component, 28 strong ones. The 33 nodes outside the largest are typically:
- a cul-de-sac entered by a one-way street
- a service road with a one-way entrance and no exit in the extracted area
- an artefact of the bounding box, where the return route runs just outside the download
That last one is worth dwelling on. Clipping a network at an arbitrary boundary severs edges, and a route that would have gone round the block now has no way back. The stranded-node count is partly a property of your extract rather than of the city β see your street network graph is disconnected.
Why edge length is metres in a degree-coordinate graph
The graph's CRS is EPSG:4326, so node x and y are degrees. But length is computed by OSMnx using great-circle distance and stored in metres.
That mixture is deliberate and convenient: routing on weight="length" is correct with no projection step, while anything geometric β buffering, area, nearest-neighbour in Euclidean space β needs ox.project_graph(G) first.
The trap is computing your own distance from node coordinates:
# WRONG β degrees
d = np.hypot(G.nodes[a]["x"] - G.nodes[b]["x"], G.nodes[a]["y"] - G.nodes[b]["y"])
# RIGHT β project first, or use the stored length
Gp = ox.project_graph(G)
d = np.hypot(Gp.nodes[a]["x"] - Gp.nodes[b]["x"], Gp.nodes[a]["y"] - Gp.nodes[b]["y"])
Why the walking network is a different graph
network_type="walk" produces 25,919 nodes against the drive network's 4,935 β five times as many β and exactly one strongly connected component.
Both differences follow from the same thing: pedestrians use paths cars cannot, and ignore restrictions cars cannot. Footpaths, alleys, steps, pedestrianised streets and crossings all appear, and no edge is one-way.
So a walking analysis needs its own download. Routing a pedestrian on a drive network overstates distances substantially and can fail entirely in a city centre with a large pedestrian zone.
Edge cases or notes
- Every edge is
(u, v, key).G[u][v]returns a dict of keys on a multigraph, not the edge data. lengthis in metres; node coordinates are in degrees. Project before doing your own geometry.- Route on the simplified graph. The raw one has five times the nodes and identical total length.
network_typechanges the graph fundamentally β drive, walk, bike and all give different node counts and different connectivity.- Weak connectivity is not enough. Check strongly connected components before routing.
- A bbox extract creates artificial stranded nodes where the return route lies just outside it. Download a larger area and clip afterwards.
onewaycan be a list on some edges where OSM has conflicting tags β do not assume it is a bool.maxspeedis often missing.ox.add_edge_speedsimputes it from the highway type; check the range it produces.
Internal links
- How to build a street network graph in Python with OSMnx β the practical construction
- Your street network graph is disconnected or missing streets β diagnosing stranded nodes
- Network distance vs straight-line distance explained β why the graph is worth the trouble
- How to calculate the shortest path along a street network β the first thing to do with it
- NetworkX says no path exists between two nodes β strong connectivity in practice
- The OpenStreetMap data model explained β where the ways come from
- How to snap points to a street network in Python β getting your own data onto the graph
- How to clean line network dangles in Python β the same connectivity problem in vector form
FAQ
What is the difference between a node and a vertex here?
A node is a junction or a street end. Vertices along a curving road between two junctions are geometry, not nodes β simplification folds them into the edge.
Why is my street network a directed graph?
So one-way streets can be represented. Two-way streets appear as two edges, one in each direction, which is why the edge count is about double the number of segments on a map.
What is the difference between weakly and strongly connected?
Weakly connected ignores direction; strongly connected respects it. Routing needs strong connectivity, and a network can be weakly connected while containing dozens of strong components.
Why does G[u][v]["length"] raise a KeyError?
It is a multigraph, so G[u][v] is a dict keyed by edge number. Use G[u][v][0]["length"], or iterate with keys=True.
Should I use the simplified graph or the raw one?
Simplified, always. It has a fifth of the nodes, the identical total length, and its nodes are almost all real junctions.
Why is the walking network so much bigger?
It includes footpaths, alleys, steps and crossings, and it has no one-way restrictions β 25,919 nodes and one strongly connected component against 4,935 and 28.
Are the edge lengths in degrees?
No β length is in metres even though node coordinates are degrees. Any distance you compute yourself from coordinates needs a projected graph.