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
A set of unconnected LineStrings beside the same streets as a graph with junction nodes and directed edges.
The lines are identical. The graph adds junctions and direction, which is what makes routing possible.

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.

A network that is one weakly connected component but splits into several strongly connected components once one-way directions are respected.
Ignore direction and the network is one piece. Respect it and 33 nodes become traps.

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.

A curving road with many degree-two vertices collapsing into one edge whose geometry retains the curve.
9,227 vertices removed, total network length unchanged. Only the junctions remain as nodes.

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.
  • length is 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_type changes 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.
  • oneway can be a list on some edges where OSM has conflicting tags β€” do not assume it is a bool.
  • maxspeed is often missing. ox.add_edge_speeds imputes it from the highway type; check the range it produces.

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.