How to Calculate Street Network Metrics in Python
On this page
Problem statement
Street networks differ in ways that matter for how people move: how many junctions there are per square kilometre, how many of them are dead ends, how directly the streets connect places, how many alternative routes a loop structure offers. Each of those is a standard metric, most of them available from osmnx in one call. The numbers are only comparable when they are computed on the same kind of network, over the same area, with the same treatment of the messy parts — dual carriageways, roundabouts and the clusters of nodes at complex junctions.
Quick answer
Project the graph, then take the basic statistics and a few derived ones:
import numpy as np, pandas as pd, osmnx as ox
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api" # overpass-api.de mirror
ox.settings.overpass_rate_limit = False
ox.settings.http_user_agent = "spatialworkflow-examples/1.0"
PLACES = {"Chicago Loop": (41.8837, -87.6325), "Barcelona Eixample": (41.3928, 2.1633),
"Den Haag centre": (52.0799, 4.3113), "Rome centro": (41.8986, 12.4768)}
AREA_KM2 = (2 * 1.5) ** 2 # graph_from_point uses a 3 × 3 km box
rows = []
for name, point in PLACES.items():
G = ox.project_graph(ox.graph_from_point(point, dist=1500, network_type="drive"))
s = ox.stats.basic_stats(G, area=AREA_KM2 * 1e6)
spn = pd.Series(s["streets_per_node_proportions"])
Gu = ox.convert.to_undirected(G)
v, e = len(Gu.nodes), len(Gu.edges)
rows.append({"place": name,
"intersections/km²": s["intersection_density_km"],
"dead ends": spn.get(1, 0),
"4-way": spn.get(4, 0),
"mean street m": s["street_length_avg"],
"circuity": s["circuity_avg"],
"meshedness": (e - v + 1) / (2 * v - 5)})
print(pd.DataFrame(rows).set_index("place").round(3).to_string())
intersections/km² dead ends 4-way mean street m circuity meshedness
place
Chicago Loop 91.667 0.056 0.515 111.445 1.027 0.342
Barcelona Eixample 120.444 0.042 0.478 87.938 1.016 0.320
Den Haag centre 129.111 0.064 0.251 79.786 1.041 0.263
Rome centro 116.111 0.092 0.242 80.329 1.054 0.241
Chicago's grid has the fewest intersections per km² and the longest streets — its blocks are big — but the most four-way junctions and the highest meshedness. Den Haag packs in the most intersections, with a quarter of them four-way. Rome has the most dead ends and the most circuitous streets. Intersection density ranks the four centres one way, dead ends another and four-way junctions a third, which is why a network is described with several numbers.
Network type, projection, area, streets per node
1. Choose network type and extent
Drive networks for traffic structure; walk or all networks for pedestrian connectivity. A fixed area makes densities comparable.
2. Project the graph
Lengths and densities need metres. ox.project_graph picks the local UTM zone.
3. Pass the area for density measures
basic_stats computes densities only when given the area in m². For a graph_from_point query that is the side of the square, 2 × dist, squared.
4. Count streets per node, not edges per node
In a directed graph a two-way street contributes two edges to each end node. osmnx's streets_per_node counts physical streets, which is what "a four-way junction" means.
5. Consolidate complex intersections for junction counts
A large junction mapped as several nodes a few metres apart counts as several intersections. ox.consolidate_intersections merges nodes within a tolerance before counting.
Consolidated junctions and circuity
The data used on this page
OpenStreetMap's drive and walk networks in a 3 × 3 km box around four centres — Chicago's Loop, Barcelona's Eixample, central Den Haag and central Rome — fetched by the quick answer through OSMnx and the maps.mail.ru Overpass mirror. The setup unpacks those answers as saved on 10 October 2026, so the junction counts stay put while the map is edited.
import io
import os
import requests
import zipfile
# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-street-network-metrics-python-2026-10-10.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
Example 1 — consolidating complex intersections
G = ox.project_graph(ox.graph_from_point(PLACES["Den Haag centre"], dist=1500,
network_type="drive"))
raw = ox.stats.basic_stats(G, area=AREA_KM2 * 1e6)["intersection_density_km"]
for tol in (5, 10, 15):
Gc = ox.consolidate_intersections(G, tolerance=tol, rebuild_graph=True, dead_ends=False)
d = ox.stats.basic_stats(Gc, area=AREA_KM2 * 1e6)["intersection_density_km"]
print(f"tolerance {tol:>2} m: {d:6.1f} intersections/km² (raw {raw:.1f})")
tolerance 5 m: 113.6 intersections/km² (raw 129.1)
tolerance 10 m: 93.4 intersections/km² (raw 129.1)
tolerance 15 m: 78.6 intersections/km² (raw 129.1)
Dual carriageways and multi-node junctions inflate the raw count. Consolidation brings it closer to the number a person walking the streets would count, and the tolerance is a parameter to report.
Example 2 — drive against walk network
out = []
for name in ("Den Haag centre", "Chicago Loop"):
for net in ("drive", "walk"):
G = ox.project_graph(ox.graph_from_point(PLACES[name], dist=1500, network_type=net))
s = ox.stats.basic_stats(G, area=AREA_KM2 * 1e6)
out.append({"place": name, "network": net,
"intersections/km²": round(s["intersection_density_km"]),
"mean street m": round(s["street_length_avg"])})
print(pd.DataFrame(out).to_string(index=False))
place network intersections/km² mean street m
Den Haag centre drive 129 80
Den Haag centre walk 406 40
Chicago Loop drive 92 111
Chicago Loop walk 870 30
The walking network multiplies junction density by three in Den Haag and by more than nine in the Loop — the opposite of what the street plans suggest. The difference is in the map as much as the city: Chicago's OSM data draws sidewalks and crossings as ways of their own, so every street corner becomes several junctions, while Den Haag's walking network is mostly the streets themselves plus footpaths. Pedestrian and vehicle grain are different properties of a city, and walk-network densities also measure how it was mapped.
Example 3 — circuity in detail
G = ox.project_graph(ox.graph_from_point(PLACES["Rome centro"], dist=1500, network_type="drive"))
edges = ox.graph_to_gdfs(ox.convert.to_undirected(G), nodes=False)
nodes = ox.graph_to_gdfs(G, edges=False)
u = nodes.loc[edges.index.get_level_values(0)].geometry.values
v = nodes.loc[edges.index.get_level_values(1)].geometry.values
straight = np.array([a.distance(b) for a, b in zip(u, v)])
ok = straight > 1
ratio = edges.length.values[ok] / straight[ok]
print(f"segment circuity: median {np.median(ratio):.3f}, 95th percentile "
f"{np.percentile(ratio, 95):.3f}, network average {edges.length.sum() / straight[ok].sum():.3f}")
segment circuity: median 1.000, 95th percentile 1.253, network average 1.055
Circuity measures how much each street segment bends between its ends. It is close to 1 even in old centres, because segments are short; circuity of routes between distant points is where irregular networks really cost distance.
Intersection density, meshedness and circuity
Why intersection density is the headline metric
Every junction is a routing choice. More junctions per km² means shorter blocks, more direct walking routes and more ways around an obstruction. It is the network metric most consistently associated with walking and cycling rates.
Why meshedness complements it
Meshedness is the number of independent loops relative to the maximum a planar graph of that size could have. A tree-like suburb of cul-de-sacs has meshedness near zero; a grid has a high one. Two networks can have similar intersection densities and very different meshedness.
Why circuity is small per segment
Streets between two junctions are mostly straight, even in medieval centres. The indirectness of irregular networks comes from how segments connect — detours between origin and destination — not from bends within segments. Route-level circuity needs shortest paths between sample points.
Simplification, loops and centrality
- Simplification: osmnx simplifies graphs by default, removing nodes that are not junctions; unsimplified graphs inflate node counts.
- Self-loops and parallel edges (e.g. a crescent returning to the same junction) are legitimate and counted.
- Area: pass the land area if the square includes water, or densities will be understated.
- Directed vs undirected: most topology metrics should be computed on the undirected graph.
- Centrality (betweenness, closeness) is available through networkx and momepy for more detailed analysis.
Internal links
- Street networks as graphs explained — nodes, edges and simplification
- How to measure street orientation entropy — orientation for the same graphs
- How to extract urban blocks from a street network — the faces of the graph
- Network vs straight-line distance explained — route circuity
- How to cluster urban fabric types — combining network and building metrics
- How to build a street network graph — getting the graph
FAQ
How do I calculate intersection density with osmnx?
Project the graph and call ox.stats.basic_stats(G, area=area_m2); the result includes intersection_density_km.
What is meshedness?
The ratio of independent loops to the maximum possible in a planar graph: near zero for tree-like networks, high for grids.
Why is my intersection count too high?
Complex junctions and dual carriageways are mapped as several nodes. Consolidate intersections with a tolerance first.
What is circuity?
Network length divided by straight-line distance, per segment or per route.
Should I use the drive or walk network?
Whichever matches the question; they describe different grains of the same city.
How do I compare cities fairly?
Same network type, same area, same consolidation tolerance.