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.

Street network metrics with their definitions and what each describes. grain, connectivity, directnessmetricdefinitiondescribesintersection densitynodes with 3+ streets per km²grain, route choicedead-end sharenodes with 1 streetcul-de-sac layouts4-way sharenodes with 4 streetsgrid-likenessmean segment lengthstreet length ÷ edgesblock scalecircuitynetwork ÷ straight distancedirectnessmeshedness(e − v + 1) ÷ (2v − 5)loops vs treeConsolidate complex junctions before counting them spatialworkflow.io
Six metrics for grain, connectivity and directness.

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 of the drivable network in a 3 × 3 km box around four city centres. intersections per km², drive networkDen Haag centre129 · 25% four-wayBarcelona Eixample120 · 48% four-wayRome centro116 · 9% dead endsChicago Loop92 · 52% four-way, longest streets070140The grid city has the fewest junctions: its blocks are large spatialworkflow.io
Junctions per square kilometre on the drivable network.

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.

Den Haag intersection density before and after consolidating junction nodes at 5, 10 and 15 m. Den Haag, intersections per km²raw OSM nodes129−15consolidated at 5 m114−21consolidated at 10 m93−14consolidated at 15 m79The tolerance is a parameter: report it with the density spatialworkflow.io
Multi-node junctions and dual carriageways inflate the raw count by two-thirds.

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.

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.